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

Merge branch 'master' into imprv/140987-145787-display-selected-TreeItem-in-PageSelectModal-as-active

Yuki Takei 1 год назад
Родитель
Сommit
e2cb8a5234
100 измененных файлов с 975 добавлено и 566 удалено
  1. 16 2
      .changeset/config.json
  2. 1 0
      .eslintrc.js
  3. 0 13
      .github/workflows/release-rc-scheduled.yml
  4. 115 0
      .github/workflows/release-subpackages.yml
  5. 22 1
      CHANGELOG.md
  6. 0 1
      apps/app/.eslintrc.js
  7. 1 1
      apps/app/docker/README.md
  8. 2 1
      apps/app/package.json
  9. 0 1
      apps/app/public/images/icons/editor/bold.svg
  10. 0 1
      apps/app/public/images/icons/editor/check.svg
  11. 0 1
      apps/app/public/images/icons/editor/code.svg
  12. 0 1
      apps/app/public/images/icons/editor/header.svg
  13. 0 1
      apps/app/public/images/icons/editor/italic.svg
  14. 0 1
      apps/app/public/images/icons/editor/list-ol.svg
  15. 0 1
      apps/app/public/images/icons/editor/list-ul.svg
  16. 0 1
      apps/app/public/images/icons/editor/picture.svg
  17. 0 1
      apps/app/public/images/icons/editor/quote.svg
  18. 0 1
      apps/app/public/images/icons/editor/strikethrough.svg
  19. 0 1
      apps/app/public/images/icons/editor/table.svg
  20. 8 0
      apps/app/public/static/locales/en_US/translation.json
  21. 2 0
      apps/app/public/static/locales/fr_FR/translation.json
  22. 8 0
      apps/app/public/static/locales/ja_JP/translation.json
  23. 8 0
      apps/app/public/static/locales/zh_CN/translation.json
  24. 0 147
      apps/app/src/client/models/MarkdownTable.js
  25. 1 2
      apps/app/src/client/services/create-page/index.ts
  26. 0 112
      apps/app/src/client/services/create-page/use-create-page-and-transit.tsx
  27. 138 0
      apps/app/src/client/services/create-page/use-create-page.tsx
  28. 7 6
      apps/app/src/client/services/create-page/use-create-template-page.ts
  29. 9 0
      apps/app/src/client/services/page-operation.ts
  30. 1 1
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  31. 69 0
      apps/app/src/components/GrantedGroupsInheritanceSelectModal.tsx
  32. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  33. 9 6
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  34. 1 1
      apps/app/src/components/Page/markdown-table-util-for-view.ts
  35. 2 2
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  36. 3 2
      apps/app/src/components/PageComment/CommentEditor.tsx
  37. 13 19
      apps/app/src/components/PageControls/PageControls.tsx
  38. 12 9
      apps/app/src/components/PageCreateModal.tsx
  39. 3 2
      apps/app/src/components/PageEditor/ConflictDiffModal.tsx
  40. 1 2
      apps/app/src/components/PageEditor/DrawioModal.tsx
  41. 2 2
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  42. 2 2
      apps/app/src/components/PageEditor/LinkEditModal.tsx
  43. 1 1
      apps/app/src/components/PageEditor/MarkdownTableDataImportForm.tsx
  44. 4 2
      apps/app/src/components/PageEditor/PageEditor.tsx
  45. 2 1
      apps/app/src/components/PageEditor/PageEditorReadOnly.tsx
  46. 2 1
      apps/app/src/components/PageEditor/ScrollSyncHelper.tsx
  47. 2 1
      apps/app/src/components/PageEditor/conflict.tsx
  48. 1 2
      apps/app/src/components/PageEditor/markdown-table-util-for-editor.ts
  49. 6 6
      apps/app/src/components/PrivateLegacyPages.tsx
  50. 12 10
      apps/app/src/components/SavePageControls.tsx
  51. 10 0
      apps/app/src/components/SearchPage.module.scss
  52. 60 22
      apps/app/src/components/SearchPage.tsx
  53. 6 2
      apps/app/src/components/SearchPage/OperateAllControl.tsx
  54. 16 24
      apps/app/src/components/SearchPage/SearchControl.tsx
  55. 13 7
      apps/app/src/components/SearchPage/SearchModalTriggerinput.tsx
  56. 3 1
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  57. 1 1
      apps/app/src/components/SearchPage/SearchResultContent.tsx
  58. 4 4
      apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx
  59. 7 4
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  60. 9 7
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  61. 6 4
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  62. 1 1
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  63. 27 19
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  64. 1 1
      apps/app/src/components/TreeItem/TreeItemLayout.tsx
  65. 27 4
      apps/app/src/features/growi-plugin/server/models/vo/github-url.ts
  66. 7 7
      apps/app/src/interfaces/page-grant.ts
  67. 1 0
      apps/app/src/interfaces/page.ts
  68. 1 1
      apps/app/src/pages/[[...path]].page.tsx
  69. 5 5
      apps/app/src/server/models/page.ts
  70. 8 5
      apps/app/src/server/routes/apiv3/page/create-page.ts
  71. 44 4
      apps/app/src/server/routes/apiv3/page/index.ts
  72. 10 7
      apps/app/src/server/routes/apiv3/page/update-page.ts
  73. 14 5
      apps/app/src/server/service/page-grant.ts
  74. 12 7
      apps/app/src/server/service/page/index.ts
  75. 4 2
      apps/app/src/server/service/user-notification/index.ts
  76. 7 2
      apps/app/src/server/service/yjs-connection-manager.ts
  77. 10 0
      apps/app/src/server/util/slack.js
  78. 33 2
      apps/app/src/stores/modal.tsx
  79. 2 2
      apps/app/src/stores/page.tsx
  80. 21 28
      apps/app/src/styles/_editor.scss
  81. 57 0
      apps/app/test/integration/service/v5.non-public-page.test.ts
  82. 2 1
      apps/slackbot-proxy/package.json
  83. 4 1
      package.json
  84. 7 0
      packages/core-styles/CHANGELOG.md
  85. 1 1
      packages/core-styles/package.json
  86. 11 0
      packages/core/CHANGELOG.md
  87. 1 1
      packages/core/package.json
  88. 18 0
      packages/core/src/interfaces/growi-theme-metadata.ts
  89. 5 1
      packages/editor/src/client/components/CodeMirrorEditor/CodeMirrorEditor.module.scss
  90. 4 7
      packages/editor/src/client/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  91. 3 3
      packages/editor/src/client/components/CodeMirrorEditor/Toolbar/AttachmentsDropdownItem.tsx
  92. 0 0
      packages/editor/src/client/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.module.scss
  93. 1 1
      packages/editor/src/client/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx
  94. 0 0
      packages/editor/src/client/components/CodeMirrorEditor/Toolbar/DiagramButton.tsx
  95. 0 0
      packages/editor/src/client/components/CodeMirrorEditor/Toolbar/EmojiButton.tsx
  96. 2 2
      packages/editor/src/client/components/CodeMirrorEditor/Toolbar/LinkEditButton.tsx
  97. 0 0
      packages/editor/src/client/components/CodeMirrorEditor/Toolbar/TableButton.tsx
  98. 0 0
      packages/editor/src/client/components/CodeMirrorEditor/Toolbar/TemplateButton.tsx
  99. 0 0
      packages/editor/src/client/components/CodeMirrorEditor/Toolbar/TextFormatTools.module.scss
  100. 1 1
      packages/editor/src/client/components/CodeMirrorEditor/Toolbar/TextFormatTools.tsx

+ 16 - 2
.changeset/config.json

@@ -4,8 +4,22 @@
   "commit": false,
   "commit": false,
   "fixed": [],
   "fixed": [],
   "linked": [],
   "linked": [],
-  "access": "restricted",
+  "access": "public",
   "baseBranch": "master",
   "baseBranch": "master",
   "updateInternalDependencies": "patch",
   "updateInternalDependencies": "patch",
-  "ignore": []
+  "snapshot": {
+    "useCalculatedVersion": true,
+    "prereleaseTemplate": "{tag}.{commit}"
+  },
+  "ignore": [
+    "@growi/app",
+    "@growi/slackbot-proxy",
+    "@growi/custom-icons",
+    "@growi/editor",
+    "@growi/presentation",
+    "@growi/preset-*",
+    "@growi/remark-*",
+    "@growi/slack",
+    "@growi/ui"
+  ]
 }
 }

+ 1 - 0
.eslintrc.js

@@ -48,6 +48,7 @@ module.exports = {
         'newlines-between': 'always',
         'newlines-between': 'always',
       },
       },
     ],
     ],
+    '@typescript-eslint/consistent-type-imports': 'warn',
     '@typescript-eslint/no-explicit-any': 'off',
     '@typescript-eslint/no-explicit-any': 'off',
     '@typescript-eslint/explicit-module-boundary-types': 'off',
     '@typescript-eslint/explicit-module-boundary-types': 'off',
     indent: [
     indent: [

+ 0 - 13
.github/workflows/release-rc-scheduled.yml

@@ -65,16 +65,3 @@ jobs:
       tag-temporary: latest-rc
       tag-temporary: latest-rc
     secrets:
     secrets:
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
       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 }}
-

+ 115 - 0
.github/workflows/release-subpackages.yml

@@ -0,0 +1,115 @@
+name: Release Subpackages
+
+on:
+  push:
+    branches:
+      - master
+    paths:
+      - .changeset/**
+      - .github/workflows/release-subpackages.yml
+  workflow_run:
+    workflows: ["Node CI for app development"]
+    types:
+      - completed
+    branches:
+      - master
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  release-subpackages-snapshot:
+
+    if: "!startsWith(github.head_ref, 'changeset-release/')"
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: actions/setup-node@v4
+      with:
+        node-version: '20'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v4
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: Install dependencies
+      run: |
+        yarn global add turbo
+        yarn global add node-gyp
+        yarn --frozen-lockfile
+
+    - name: Setup .npmrc
+      run: |
+        cat << EOF > "$HOME/.npmrc"
+          //registry.npmjs.org/:_authToken=$NPM_TOKEN
+        EOF
+      env:
+        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+    - name: Retrieve changesets information
+      id: changesets-status
+      run: |
+        yarn changeset status --output status.json
+        echo "CHANGESETS_LENGTH=$(jq -r '.changesets | length' status.json)" >> $GITHUB_OUTPUT
+        rm status.json
+
+    - name: Snapshot release to npm
+      if: steps.changesets-status.outputs.CHANGESETS_LENGTH > 0
+      run: |
+        yarn release-subpackages:snapshot
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+
+  release-subpackages:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: actions/setup-node@v4
+      with:
+        node-version: '20'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v4
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: Install dependencies
+      run: |
+        yarn global add turbo
+        yarn global add node-gyp
+        yarn --frozen-lockfile
+
+    - name: Create Release Pull Request or Publish to npm
+      id: changesets
+      uses: changesets/action@v1
+      with:
+        title: Release Subpackages
+        version: yarn version-subpackages
+        publish: yarn release-subpackages
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

+ 22 - 1
CHANGELOG.md

@@ -1,9 +1,30 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.7...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.8...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v7.0.8](https://github.com/weseek/growi/compare/v7.0.7...v7.0.8) - 2024-05-30
+
+### 💎 Features
+
+* feat: Select unrelated group inheritance on child page create (#8812) @arafubeatbox
+
+### 🚀 Improvement
+
+* imprv: Design coding of search result page (#8828) @miya
+
+### 🐛 Bug Fixes
+
+* fix: Page body sometimes appears doubled up when the editor is opened (#8858) @miya
+* fix: Brackets appearance when Nord editor theme (#8852) @satof3
+* fix: Slack notification not sent on page update (#8841) @miya
+* fix: Table icon is not displayed when hovering over the table (#8830) @WNomunomu
+
+### 🧰 Maintenance
+
+* support: Reorganize editor module exports (#8846) @yuki-takei
+
 ## [v7.0.7](https://github.com/weseek/growi/compare/v7.0.6...v7.0.7) - 2024-05-27
 ## [v7.0.7](https://github.com/weseek/growi/compare/v7.0.6...v7.0.7) - 2024-05-27
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

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

@@ -27,7 +27,6 @@ module.exports = {
       },
       },
     ]],
     ]],
     '@typescript-eslint/no-var-requires': 'off',
     '@typescript-eslint/no-var-requires': 'off',
-    '@typescript-eslint/consistent-type-imports': 'warn',
 
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     '@typescript-eslint/no-use-before-define': ['warn'],
     '@typescript-eslint/no-use-before-define': ['warn'],

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

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`7.0.7`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.7/apps/app/docker/Dockerfile)
+* [`7.0.8`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.8/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)

+ 2 - 1
apps/app/package.json

@@ -1,7 +1,8 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.0.8-RC.0",
+  "version": "7.0.9-RC.0",
   "license": "MIT",
   "license": "MIT",
+  "private": "true",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
     "build": "run-p build:*",
     "build": "run-p build:*",

+ 0 - 1
apps/app/public/images/icons/editor/bold.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="109" height="140" viewBox="0 0 10.9 14"><path d="M0 0h5.6c3 0 4.7 1.1 4.7 3.4a3.1 3.1 0 0 1-2.5 3.1 3.7 3.7 0 0 1 3.1 3.5c0 2.9-1.4 4-4.2 4H0zm5.2 6.5c2.7 0 2.6-1.4 2.6-3.1S7.9.7 5.6.7H2.3v5.8zm-2.9 6.6h3.4c2.1 0 2.7-1.1 2.7-3.1s0-2.8-3.2-2.8H2.3z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/check.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="144" height="160" viewBox="0 0 14.4 16"><path d="M13.9 5.5a.5.5 0 0 1 .5.5v9a1.1 1.1 0 0 1-1.1 1H1a1.1 1.1 0 0 1-1-1V2.6a1.1 1.1 0 0 1 1-1h7.1a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5H1V15h12.3V6a.6.6 0 0 1 .6-.5zM3.6 8.3a.5.5 0 0 0 0 .7l2.5 2.5a.8.8 0 0 0 1.1 0h.1l7-10.7c.1-.2.1-.6-.2-.7a.5.5 0 0 0-.7.1L6.6 10.6 4.3 8.3a.5.5 0 0 0-.7 0z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/code.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="181" height="140" viewBox="0 0 18.1 14"><path d="M17.8 7.9l-4 3.8a.5.5 0 0 1-.8 0 .5.5 0 0 1 0-.8L16.8 7 13 3.2a.6.6 0 0 1 0-.9.5.5 0 0 1 .8 0l4 3.8a1.3 1.3 0 0 1 0 1.8zM5.2 2.3a.7.7 0 0 1 0 .9L1.3 7l3.9 3.9a.6.6 0 0 1 0 .8.6.6 0 0 1-.9 0L.4 7.9a1.3 1.3 0 0 1 0-1.8l3.9-3.8a.6.6 0 0 1 .9 0zM11.5.8L7.8 13.6a.6.6 0 0 1-.7.4.6.6 0 0 1-.5-.8L10.3.4a.7.7 0 0 1 .8-.4.6.6 0 0 1 .4.8z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/header.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="137" height="140" viewBox="0 0 13.7 14"><path d="M10.2 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v11.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-2.9a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.8V7.2H2.7v5.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6H.6a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.7V1.2H.6A.6.6 0 0 1 0 .6.6.6 0 0 1 .6 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v4.9H11V1.2h-.8a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/italic.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="86" height="139" viewBox="0 0 8.6 13.9"><path d="M8.1 0a.6.6 0 0 1 .5.6c0 .3-.2.6-.7.6H6.2L3.8 12.8h1.8c.2 0 .4.3.4.5a.7.7 0 0 1-.7.6H.5c-.3 0-.5-.4-.5-.6s.4-.6.7-.6h1.7L4.9 1.2H3.1a.5.5 0 0 1-.5-.5c0-.3.1-.7.8-.7z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/list-ol.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="237" height="160" viewBox="0 0 23.7 16"><path d="M23.7 2a.8.8 0 0 1-.8.8H6.6a.8.8 0 0 1-.7-.8.7.7 0 0 1 .7-.7h16.3a.7.7 0 0 1 .8.7zM6.6 8.7h16.3a.7.7 0 0 0 .8-.7.8.8 0 0 0-.8-.8H6.6a.8.8 0 0 0-.7.8.7.7 0 0 0 .7.7zm0 5.9h16.3a.7.7 0 0 0 .8-.7.7.7 0 0 0-.8-.7H6.6a.7.7 0 0 0-.7.7.7.7 0 0 0 .7.7zM1.5.5V4h.6V0h-.5L.7.5v.4l.8-.4zM.9 9.6l.3-.3c.9-.9 1.4-1.5 1.4-2.2a1.2 1.2 0 0 0-1.3-1.2h-.1a1.4 1.4 0 0 0-1.2.6l.3.4a1.2 1.2 0 0 1 .9-.5.6.6 0 0 1 .8.6v.2c0 .6-.4 1.1-1.5 2.1l-.4.4v.3h2.6v-.4zm.9 4.1a1 1 0 0 0 .7-.9 1 1 0 0 0-1.1-1 2 2 0 0 0-1.1.3v.4l.8-.2c.5 0 .8.2.8.6s-.5.7-.9.7H.7v.4H1c.6 0 1.1.2 1.1.8a.8.8 0 0 1-.9.8l-.9-.3-.2.4a2 2 0 0 0 1.1.3c1 0 1.5-.6 1.5-1.2a1.2 1.2 0 0 0-.9-1.1z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/list-ul.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="216" height="135" viewBox="0 0 21.6 13.5"><path d="M6.4 1.5h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zM.9 1.5h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.7h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.7z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/picture.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="190" height="160" viewBox="0 0 19 16"><path d="M17.8 0H1.2A1.2 1.2 0 0 0 0 1.2v13.6A1.2 1.2 0 0 0 1.2 16h16.6a1.2 1.2 0 0 0 1.2-1.2V1.2a1.4 1.4 0 0 0-.2-.6.8.8 0 0 0-.4-.4zm0 14.8H1.2v-3.5l4.7-4.6 5 4.9.3.2.5-.2 2.1-1.9 3.9 4h.1v1.1zm0-2.8l-3.5-3.5-.4-.2h-.4l-2.2 2-4.9-4.8-.4-.2c-.2 0-.4 0-.5.2L1.2 9.7V1.2h16.6V12zm-4.2-6.1h.6a1.1 1.1 0 0 0 .6-1.1 1.2 1.2 0 0 0-1.2-1.1 1.3 1.3 0 0 0-1.2 1.2 1.2 1.2 0 0 0 .4.8 1.1 1.1 0 0 0 .8.3z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/quote.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="170" height="120" viewBox="0 0 17 12"><path d="M5 0H2a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1H3v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6H2a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1zm10-6h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1h-2v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6h-3a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/strikethrough.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="195" height="140" viewBox="0 0 19.5 14"><path d="M5.8 6.2H9C7.2 5.7 6.3 5 6.3 3.8a2.2 2.2 0 0 1 .9-1.9 4.3 4.3 0 0 1 2.5-.7 4.3 4.3 0 0 1 2.5.7 3.1 3.1 0 0 1 1.1 1.6.7.7 0 0 0 .6.4h.3a.7.7 0 0 0 .4-.8A3.6 3.6 0 0 0 13.1 1a6.7 6.7 0 0 0-6-.5 3.1 3.1 0 0 0-1.7 1.3 3.6 3.6 0 0 0-.6 2 2.9 2.9 0 0 0 1 2.3zm7 2.5a2 2 0 0 1 .6 1.4 2.4 2.4 0 0 1-1 1.9 3.7 3.7 0 0 1-2.5.7 4.6 4.6 0 0 1-3-.8 3.7 3.7 0 0 1-1.2-2 .6.6 0 0 0-.6-.5h-.2a.7.7 0 0 0-.5.8 4.1 4.1 0 0 0 1.5 2.5A6 6 0 0 0 9.8 14a7.5 7.5 0 0 0 2.6-.5 4.9 4.9 0 0 0 1.8-1.4 4.3 4.3 0 0 0 .6-2.2 5 5 0 0 0-.2-1.2zM.4 7.9a.7.7 0 0 1-.4-.5.4.4 0 0 1 .4-.4h18.8a.4.4 0 0 1 .3.6c0 .1-.1.2-.2.3z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/table.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="203" height="160" viewBox="0 0 20.3 16"><path d="M19.1 16H1.2A1.2 1.2 0 0 1 0 14.8V1.2A1.2 1.2 0 0 1 1.2 0h17.9a1.2 1.2 0 0 1 1.2 1.2v13.6a1.2 1.2 0 0 1-1.2 1.2zm-5.2-4.3v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm12.8-4.2v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm12.8-4.3v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2z"/></svg>

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

@@ -425,6 +425,12 @@
     }
     }
   },
   },
   "duplicated_pages": "{{fromPath}} has been duplicated",
   "duplicated_pages": "{{fromPath}} has been duplicated",
+  "modal_granted_groups_inheritance_select": {
+    "select_granted_groups": "Select groups that can access page",
+    "inherit_all_granted_groups_from_parent": "Inherit all groups that can access page from parent",
+    "only_inherit_related_groups": "Only inherit groups that you belong to from parent",
+    "create_page": "Create Page"
+  },
   "modal_putback": {
   "modal_putback": {
     "label": {
     "label": {
       "Put Back Page": "Put back page",
       "Put Back Page": "Put back page",
@@ -561,6 +567,8 @@
     "delete_completely": "Delete completely",
     "delete_completely": "Delete completely",
     "include_certain_path": "Include {{pathToInclude}} path ",
     "include_certain_path": "Include {{pathToInclude}} path ",
     "delete_all_selected_page": "Delete All",
     "delete_all_selected_page": "Delete All",
+    "select_all": "Select all",
+    "delete_selected_pages": "Delete selected pages",
     "currently_not_implemented": "This is not currently implemented",
     "currently_not_implemented": "This is not currently implemented",
     "search_again": "Search again",
     "search_again": "Search again",
     "number_of_list_to_display": "Display",
     "number_of_list_to_display": "Display",

+ 2 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -561,6 +561,8 @@
     "delete_completely": "Supprimer définitivement",
     "delete_completely": "Supprimer définitivement",
     "include_certain_path": "Inclure le chemin {{pathToInclude}} ",
     "include_certain_path": "Inclure le chemin {{pathToInclude}} ",
     "delete_all_selected_page": "Tout supprimer",
     "delete_all_selected_page": "Tout supprimer",
+    "select_all": "Tout sélectionner",
+    "delete_selected_pages": "Supprimer les pages sélectionnées",
     "currently_not_implemented": "Non implémenté",
     "currently_not_implemented": "Non implémenté",
     "search_again": "Rechercher",
     "search_again": "Rechercher",
     "number_of_list_to_display": "Afficher",
     "number_of_list_to_display": "Afficher",

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

@@ -458,6 +458,12 @@
     }
     }
   },
   },
   "duplicated_pages": "{{fromPath}} を複製しました",
   "duplicated_pages": "{{fromPath}} を複製しました",
+  "modal_granted_groups_inheritance_select": {
+    "select_granted_groups": "閲覧権限のあるグループを選択",
+    "inherit_all_granted_groups_from_parent": "閲覧権限のあるグループを親ページから全て引き継ぐ",
+    "only_inherit_related_groups": "自分が所属するグループのみを親ページから引き継ぐ",
+    "create_page": "ページ作成"
+  },
   "modal_putback": {
   "modal_putback": {
     "label": {
     "label": {
       "Put Back Page": "ページを元に戻す",
       "Put Back Page": "ページを元に戻す",
@@ -594,6 +600,8 @@
     "delete_completely": "完全に削除する",
     "delete_completely": "完全に削除する",
     "include_certain_path": "{{pathToInclude}}下を含む ",
     "include_certain_path": "{{pathToInclude}}下を含む ",
     "delete_all_selected_page": "一括削除",
     "delete_all_selected_page": "一括削除",
+    "select_all": "全件選択",
+    "delete_selected_pages": "選択したページを削除",
     "currently_not_implemented": "現在未実装の機能です",
     "currently_not_implemented": "現在未実装の機能です",
     "search_again": "再検索",
     "search_again": "再検索",
     "number_of_list_to_display": "表示件数",
     "number_of_list_to_display": "表示件数",

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

@@ -415,6 +415,12 @@
     }
     }
   },
   },
   "duplicated_pages": "{{fromPath}} 已重复",
   "duplicated_pages": "{{fromPath}} 已重复",
+  "modal_granted_groups_inheritance_select": {
+    "select_granted_groups": "Select groups that can access page",
+    "inherit_all_granted_groups_from_parent": "Inherit all groups that can access page from parent",
+    "only_inherit_related_groups": "Only inherit groups that you belong to from parent",
+    "create_page": "Create Page"
+  },
   "modal_putback": {
   "modal_putback": {
     "label": {
     "label": {
       "Put Back Page": "Put back page",
       "Put Back Page": "Put back page",
@@ -564,6 +570,8 @@
     "delete_completely": "完全删除",
     "delete_completely": "完全删除",
     "include_certain_path": "包含 {{pathToInclude}} 路径 ",
     "include_certain_path": "包含 {{pathToInclude}} 路径 ",
     "delete_all_selected_page": "删除所有",
     "delete_all_selected_page": "删除所有",
+    "select_all": "选择所有",
+    "delete_selected_pages": "删除选定页面",
     "currently_not_implemented": "这是当前未实现的功能",
     "currently_not_implemented": "这是当前未实现的功能",
     "search_again": "再次搜索",
     "search_again": "再次搜索",
     "number_of_list_to_display": "显示器的数量",
     "number_of_list_to_display": "显示器的数量",

+ 0 - 147
apps/app/src/client/models/MarkdownTable.js

@@ -1,147 +0,0 @@
-import csvToMarkdown from 'csv-to-markdown-table';
-import { markdownTable } from 'markdown-table';
-import stringWidth from 'string-width';
-
-// https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83
-// https://regex101.com/r/7BN2fR/7
-const tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
-const tableAlignmentLineNegRE = /^[^-:]*$/; // it is need to check to ignore empty row which is matched above RE
-const linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^|\r\n]+\|[^|\r\n]*)+/; // own idea
-
-const defaultOptions = { stringLength: stringWidth };
-
-/**
- * markdown table class for markdown-table module
- *   ref. https://github.com/wooorm/markdown-table
- */
-export default class MarkdownTable {
-
-  constructor(table, options) {
-    this.table = table || [];
-    this.options = Object.assign(options || {}, defaultOptions);
-
-    this.toString = this.toString.bind(this);
-  }
-
-  toString() {
-    return markdownTable(this.table, this.options);
-  }
-
-  /**
-   * returns cloned Markdowntable instance
-   * (This method clones only the table field.)
-   */
-  clone() {
-    const newTable = [];
-    for (let i = 0; i < this.table.length; i++) {
-      newTable.push([].concat(this.table[i]));
-    }
-    return new MarkdownTable(newTable, this.options);
-  }
-
-  /**
-   * normalize all cell data(trim & convert the newline character to space or pad '' if cell data is null)
-   */
-  normalizeCells() {
-    for (let i = 0; i < this.table.length; i++) {
-      for (let j = 0; j < this.table[i].length; j++) {
-        if (this.table[i][j] != null) {
-          this.table[i][j] = this.table[i][j].trim().replace(/\r?\n/g, ' ');
-        }
-        else {
-          this.table[i][j] = '';
-        }
-      }
-    }
-
-    return this;
-  }
-
-  /**
-   * return a MarkdownTable instance made from a string of HTML table tag
-   *
-   * If a parser error occurs, an error object with an error message is thrown.
-   * The error message is a innerHTML, so must not assign it into element.innerHTML because it can lead to Mutation-based XSS
-   */
-  static fromHTMLTableTag(str) {
-    // set up DOMParser
-    const domParser = new (window.DOMParser)();
-
-    // use DOMParser to prevent DOM based XSS (https://developer.mozilla.org/en-US/docs/Web/API/DOMParser)
-    const dom = domParser.parseFromString(str, 'application/xml');
-
-    if (dom.querySelector('parsererror')) {
-      throw new Error(dom.documentElement.innerHTML);
-    }
-
-    const tableElement = dom.querySelector('table');
-    const trElements = tableElement.querySelectorAll('tr');
-
-    const table = [];
-    let maxRowSize = 0;
-    for (let i = 0; i < trElements.length; i++) {
-      const row = [];
-      const cellElements = trElements[i].querySelectorAll('th,td');
-      for (let j = 0; j < cellElements.length; j++) {
-        row.push(cellElements[j].innerHTML);
-      }
-      table.push(row);
-
-      if (maxRowSize < row.length) maxRowSize = row.length;
-    }
-
-    const align = [];
-    for (let i = 0; i < maxRowSize; i++) {
-      align.push('');
-    }
-
-    return new MarkdownTable(table, { align });
-  }
-
-  /**
-   * return a MarkdownTable instance made from a string of delimiter-separated values
-   */
-  static fromDSV(str, delimiter) {
-    return MarkdownTable.fromMarkdownString(csvToMarkdown(str, delimiter, true));
-  }
-
-  /**
-   * return a MarkdownTable instance
-   *   ref. https://github.com/wooorm/markdown-table
-   * @param {string} str markdown string
-   */
-  static fromMarkdownString(str) {
-    const arrMDTableLines = str.split(/(\r\n|\r|\n)/);
-    const contents = [];
-    let aligns = [];
-    for (let n = 0; n < arrMDTableLines.length; n++) {
-      const line = arrMDTableLines[n];
-
-      if (tableAlignmentLineRE.test(line) && !tableAlignmentLineNegRE.test(line)) {
-        // parse line which described alignment
-        const alignRuleRE = [
-          { align: 'c', regex: /^:-+:$/ },
-          { align: 'l', regex: /^:-+$/ },
-          { align: 'r', regex: /^-+:$/ },
-        ];
-        let lineText = '';
-        lineText = line.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
-        lineText = lineText.replace(/\s*/g, '');
-        aligns = lineText.split(/\|/).map((col) => {
-          const rule = alignRuleRE.find((rule) => { return col.match(rule.regex) });
-          return (rule != null) ? rule.align : '';
-        });
-      }
-      else if (linePartOfTableRE.test(line)) {
-        // parse line whether header or body
-        let lineText = '';
-        lineText = line.replace(/\s*\|\s*/g, '|');
-        lineText = lineText.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
-        const row = lineText.split(/\|/);
-        contents.push(row);
-      }
-    }
-    return (new MarkdownTable(contents, { align: aligns }));
-  }
-
-}

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

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

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

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

+ 138 - 0
apps/app/src/client/services/create-page/use-create-page.tsx

@@ -0,0 +1,138 @@
+import { useCallback, useState } from 'react';
+
+import { useRouter } from 'next/router';
+import { useTranslation } from 'react-i18next';
+
+import { exist, getIsNonUserRelatedGroupsGranted } from '~/client/services/page-operation';
+import { toastWarning } from '~/client/util/toastr';
+import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
+import { useGrantedGroupsInheritanceSelectModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
+import { createPage } from './create-page';
+
+/**
+ * Invoked when creation and transition has finished
+ */
+type OnCreated = () => void;
+/**
+ * Invoked when either creation or transition has aborted
+ */
+type OnAborted = () => void;
+/**
+ * Always invoked after processing is terminated
+ */
+type OnTerminated = () => void;
+
+export type CreatePageOpts = {
+  skipPageExistenceCheck?: boolean,
+  skipTransition?: boolean,
+  onCreationStart?: OnCreated,
+  onCreated?: OnCreated,
+  onAborted?: OnAborted,
+  onTerminated?: OnTerminated,
+}
+
+type CreatePage = (
+  params: IApiv3PageCreateParams,
+  opts?: CreatePageOpts,
+) => Promise<void>;
+
+type UseCreatePage = () => {
+  isCreating: boolean,
+  create: CreatePage,
+};
+
+export const useCreatePage: UseCreatePage = () => {
+
+  const router = useRouter();
+  const { t } = useTranslation();
+
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { mutate: mutateEditorMode } = useEditorMode();
+  const { open: openGrantedGroupsInheritanceSelectModal, close: closeGrantedGroupsInheritanceSelectModal } = useGrantedGroupsInheritanceSelectModal();
+
+  const [isCreating, setCreating] = useState(false);
+
+  const create: CreatePage = useCallback(async(params, opts = {}) => {
+    const {
+      onCreationStart, onCreated, onAborted, onTerminated,
+    } = opts;
+    const skipPageExistenceCheck = opts.skipPageExistenceCheck ?? false;
+    const skipTransition = opts.skipTransition ?? false;
+
+    // check the page existence
+    if (!skipPageExistenceCheck && params.path != null) {
+      const pagePath = params.path;
+
+      try {
+        const { isExist } = await exist(pagePath);
+
+        if (isExist) {
+          if (!skipTransition) {
+            // routing
+            if (pagePath !== currentPagePath) {
+              await router.push(`${pagePath}#edit`);
+            }
+            mutateEditorMode(EditorMode.Editor);
+          }
+          else {
+            toastWarning(t('duplicated_page_alert.same_page_name_exists', { pageName: pagePath }));
+          }
+          onAborted?.();
+          return;
+        }
+      }
+      catch (err) {
+        throw err;
+      }
+      finally {
+        onTerminated?.();
+      }
+    }
+
+    const _create = async(onlyInheritUserRelatedGrantedGroups?: boolean) => {
+      try {
+        setCreating(true);
+        onCreationStart?.();
+
+        params.onlyInheritUserRelatedGrantedGroups = onlyInheritUserRelatedGrantedGroups;
+        const response = await createPage(params);
+
+        closeGrantedGroupsInheritanceSelectModal();
+
+        if (!skipTransition) {
+          await router.push(`/${response.page._id}#edit`);
+          mutateEditorMode(EditorMode.Editor);
+        }
+
+        onCreated?.();
+      }
+      catch (err) {
+        throw err;
+      }
+      finally {
+        onTerminated?.();
+        setCreating(false);
+      }
+    };
+
+    // If parent page is granted to non-user-related groups, let the user select whether or not to inherit them.
+    if (params.parentPath != null) {
+      const { isNonUserRelatedGroupsGranted } = await getIsNonUserRelatedGroupsGranted(params.parentPath);
+      if (isNonUserRelatedGroupsGranted) {
+        // create and transit request will be made from modal
+        openGrantedGroupsInheritanceSelectModal(_create);
+        return;
+      }
+    }
+
+    await _create();
+  }, [currentPagePath, mutateEditorMode, router, openGrantedGroupsInheritanceSelectModal, closeGrantedGroupsInheritanceSelectModal, t]);
+
+  return {
+    isCreating,
+    create,
+  };
+};

+ 7 - 6
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -8,7 +8,7 @@ import type { LabelType } from '~/interfaces/template';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
 
 
 
 
-import { useCreatePageAndTransit } from './use-create-page-and-transit';
+import { useCreatePage } from './use-create-page';
 
 
 type UseCreateTemplatePage = () => {
 type UseCreateTemplatePage = () => {
   isCreatable: boolean,
   isCreatable: boolean,
@@ -20,17 +20,18 @@ export const useCreateTemplatePage: UseCreateTemplatePage = () => {
 
 
   const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
   const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
 
 
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
   const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
   const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
 
 
   const createTemplate = useCallback(async(label: LabelType) => {
   const createTemplate = useCallback(async(label: LabelType) => {
     if (isLoadingPagePath || !isCreatable) return;
     if (isLoadingPagePath || !isCreatable) return;
 
 
-    return createAndTransit(
-      { path: normalizePath(`${currentPagePath}/${label}`), wip: false, origin: Origin.View },
-      { shouldCheckPageExists: true },
+    return create(
+      {
+        path: normalizePath(`${currentPagePath}/${label}`), parentPath: currentPagePath, wip: false, origin: Origin.View,
+      },
     );
     );
-  }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);
+  }, [currentPagePath, isCreatable, isLoadingPagePath, create]);
 
 
   return {
   return {
     isCreatable,
     isCreatable,

+ 9 - 0
apps/app/src/client/services/page-operation.ts

@@ -156,6 +156,15 @@ export const exist = async(path: string): Promise<PageExistResponse> => {
   return res.data;
   return res.data;
 };
 };
 
 
+interface NonUserRelatedGroupsGrantedResponse {
+  isNonUserRelatedGroupsGranted: boolean,
+}
+
+export const getIsNonUserRelatedGroupsGranted = async(path: string): Promise<NonUserRelatedGroupsGrantedResponse> => {
+  const res = await apiv3Get<NonUserRelatedGroupsGrantedResponse>('/page/non-user-related-groups-granted', { path });
+  return res.data;
+};
+
 export const publish = async(pageId: string): Promise<IPageHasId> => {
 export const publish = async(pageId: string): Promise<IPageHasId> => {
   const res = await apiv3Put(`/page/${pageId}/publish`);
   const res = await apiv3Put(`/page/${pageId}/publish`);
   return res.data;
   return res.data;

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

@@ -3,8 +3,8 @@ import { useCallback, useEffect } from 'react';
 import type EventEmitter from 'events';
 import type EventEmitter from 'events';
 
 
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
+import type { MarkdownTable } from '@growi/editor';
 
 
-import type MarkdownTable from '~/client/models/MarkdownTable';
 import { extractRemoteRevisionDataFromErrorObj, updatePage as _updatePage } from '~/client/services/update-page';
 import { extractRemoteRevisionDataFromErrorObj, updatePage as _updatePage } from '~/client/services/update-page';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
 import { useShareLinkId } from '~/stores/context';
 import { useShareLinkId } from '~/stores/context';

+ 69 - 0
apps/app/src/components/GrantedGroupsInheritanceSelectModal.tsx

@@ -0,0 +1,69 @@
+import { useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { useGrantedGroupsInheritanceSelectModal } from '~/stores/modal';
+
+const GrantedGroupsInheritanceSelectModal = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: modalData, close: closeModal } = useGrantedGroupsInheritanceSelectModal();
+  const [onlyInheritUserRelatedGrantedGroups, setOnlyInheritUserRelatedGrantedGroups] = useState(false);
+
+  const onCreateBtnClick = async() => {
+    await modalData?.onCreateBtnClick?.(onlyInheritUserRelatedGrantedGroups);
+    setOnlyInheritUserRelatedGrantedGroups(false); // reset to false after create request
+  };
+  const isOpened = modalData?.isOpened ?? false;
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={() => closeModal()}
+    >
+      <ModalHeader tag="h4" toggle={() => closeModal()}>
+        {t('modal_granted_groups_inheritance_select.select_granted_groups')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="px-3 pt-3">
+          <div className="form-check radio-primary mb-3">
+            <input
+              type="radio"
+              id="inheritAllGroupsRadio"
+              className="form-check-input"
+              form="formImageType"
+              checked={!onlyInheritUserRelatedGrantedGroups}
+              onChange={() => { setOnlyInheritUserRelatedGrantedGroups(false) }}
+            />
+            <label className="form-check-label" htmlFor="inheritAllGroupsRadio">
+              {t('modal_granted_groups_inheritance_select.inherit_all_granted_groups_from_parent')}
+            </label>
+          </div>
+          <div className="form-check radio-primary">
+            <input
+              type="radio"
+              id="onlyInheritRelatedGroupsRadio"
+              className="form-check-input"
+              form="formImageType"
+              checked={onlyInheritUserRelatedGrantedGroups}
+              onChange={() => { setOnlyInheritUserRelatedGrantedGroups(true) }}
+            />
+            <label className="form-check-label" htmlFor="onlyInheritRelatedGroupsRadio">
+              {t('modal_granted_groups_inheritance_select.only_inherit_related_groups')}
+            </label>
+          </div>
+        </div>
+      </ModalBody>
+      <ModalFooter className="grw-modal-footer">
+        <button type="button" className="me-2 btn btn-secondary" onClick={() => closeModal()}>{t('Cancel')}</button>
+        <button className="btn btn-primary" type="button" onClick={onCreateBtnClick}>
+          {t('modal_granted_groups_inheritance_select.create_page')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default GrantedGroupsInheritanceSelectModal;

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

@@ -29,6 +29,7 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 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 GrantedGroupsInheritanceSelectModal = dynamic(() => import('../GrantedGroupsInheritanceSelectModal'), { 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 });
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
 
 
@@ -72,6 +73,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <HotkeysManager />
       <HotkeysManager />
 
 
       <ShortcutsModal />
       <ShortcutsModal />
+      <GrantedGroupsInheritanceSelectModal />
       <SystemVersion showShortcutsButton />
       <SystemVersion showShortcutsButton />
     </RawLayout>
     </RawLayout>
   );
   );

+ 9 - 6
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,9 +1,10 @@
 import React, { type ReactNode, useCallback, useMemo } from 'react';
 import React, { type ReactNode, useCallback, useMemo } from 'react';
 
 
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
@@ -67,7 +68,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { data: currentPageYjsData } = useCurrentPageYjsData();
   const { data: currentPageYjsData } = useCurrentPageYjsData();
 
 
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
 
 
   const editButtonClickedHandler = useCallback(async() => {
   const editButtonClickedHandler = useCallback(async() => {
     if (isNotFound == null || isNotFound === false) {
     if (isNotFound == null || isNotFound === false) {
@@ -76,15 +77,17 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     }
     }
 
 
     try {
     try {
-      await createAndTransit(
-        { path, wip: shouldCreateWipPage(path), origin: Origin.View },
-        { shouldCheckPageExists: true },
+      const parentPath = path != null ? normalizePath(path.split('/').slice(0, -1).join('/')) : undefined; // does not have to exist
+      await create(
+        {
+          path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
+        },
       );
       );
     }
     }
     catch (err) {
     catch (err) {
       toastError(t('toaster.create_failed', { target: path }));
       toastError(t('toaster.create_failed', { target: path }));
     }
     }
-  }, [createAndTransit, isNotFound, mutateEditorMode, path, t]);
+  }, [create, isNotFound, mutateEditorMode, path, t]);
 
 
   const _isBtnDisabled = isCreating || isBtnDisabled;
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
 

+ 1 - 1
apps/app/src/components/Page/markdown-table-util-for-view.ts

@@ -1,4 +1,4 @@
-import MarkdownTable from '~/client/models/MarkdownTable';
+import { MarkdownTable } from '@growi/editor';
 
 
 export const getMarkdownTableFromLine = (markdown: string, bol: number, eol: number): MarkdownTable => {
 export const getMarkdownTableFromLine = (markdown: string, bol: number, eol: number): MarkdownTable => {
   const tableLines = markdown.split(/\r\n|\r|\n/).slice(bol - 1, eol).join('\n');
   const tableLines = markdown.split(/\r\n|\r|\n/).slice(bol - 1, eol).join('\n');

+ 2 - 2
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -9,7 +9,7 @@ import {
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { UserGroupPageGrantStatus, type IPageGrantData } from '~/interfaces/page';
 import { UserGroupPageGrantStatus, type IPageGrantData } from '~/interfaces/page';
-import type { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import type { PopulatedGrantedGroup, IRecordApplicableGrant, IResGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxCurrentGrantData, useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxApplicableGrant, useSWRxCurrentGrantData, useSWRxCurrentPage } from '~/stores/page';
 
 
@@ -17,7 +17,7 @@ type ModalProps = {
   isOpen: boolean
   isOpen: boolean
   pageId: string
   pageId: string
   dataApplicableGrant: IRecordApplicableGrant
   dataApplicableGrant: IRecordApplicableGrant
-  currentAndParentPageGrantData: IResIsGrantNormalizedGrantData
+  currentAndParentPageGrantData: IResGrantData
   close(): void
   close(): void
 }
 }
 
 

+ 3 - 2
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -4,9 +4,10 @@ import React, {
   useMemo,
   useMemo,
 } from 'react';
 } from 'react';
 
 
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import {
 import {
-  CodeMirrorEditorComment, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
-} from '@growi/editor';
+  CodeMirrorEditorComment, useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
+} from '@growi/editor/dist/client';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';

+ 13 - 19
apps/app/src/components/PageControls/PageControls.tsx

@@ -10,13 +10,12 @@ import {
 } from '@growi/core';
 } from '@growi/core';
 import { useRect } from '@growi/ui/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import { DropdownItem } from 'reactstrap';
 
 
 import {
 import {
   toggleLike, toggleSubscribe,
   toggleLike, toggleSubscribe,
 } from '~/client/services/page-operation';
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
-import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
+import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/stores/context';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
 import {
 import {
   EditorMode, useEditorMode, useIsDeviceLargerThanMd, usePageControlsX,
   EditorMode, useEditorMode, useIsDeviceLargerThanMd, usePageControlsX,
@@ -66,7 +65,7 @@ const Tags = (props: TagsProps): JSX.Element => {
 };
 };
 
 
 type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
 type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
-  onClickMenuItem: () => void,
+  onChange: () => void,
   expandContentWidth?: boolean,
   expandContentWidth?: boolean,
 }
 }
 
 
@@ -74,26 +73,20 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const {
   const {
-    onClickMenuItem, expandContentWidth,
+    onChange, expandContentWidth,
   } = props;
   } = props;
 
 
-  const menuItemClickedHandler = useCallback((e: React.MouseEvent<HTMLInputElement>) => {
-    e.preventDefault();
-    onClickMenuItem();
-  }, [onClickMenuItem]);
-
   return (
   return (
-    <div
-      className="grw-page-control-dropdown-item dropdown-item"
-      onClick={menuItemClickedHandler}
-    >
-      <div className="form-check form-switch ms-1">
+    <div className="grw-page-control-dropdown-item dropdown-item">
+      <div className="form-check form-switch ms-1 flex-fill d-flex">
         <input
         <input
+          id="wide-view-checkbox"
           className="form-check-input"
           className="form-check-input"
           type="checkbox"
           type="checkbox"
-          checked={expandContentWidth}
+          defaultChecked={expandContentWidth}
+          onChange={onChange}
         />
         />
-        <label className="form-label form-check-label">
+        <label className="form-check-label flex-grow-1 ms-2" htmlFor="wide-view-checkbox">
           { t('wide_view') }
           { t('wide_view') }
         </label>
         </label>
       </div>
       </div>
@@ -136,6 +129,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const { data: isSearchPage } = useIsSearchPage();
 
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
 
@@ -255,7 +249,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     }
     }
     const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => {
     const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => {
 
 
-      return <WideViewMenuItem {...props} onClickMenuItem={switchContentWidthClickHandler} expandContentWidth={expandContentWidth} />;
+      return <WideViewMenuItem {...props} onChange={switchContentWidthClickHandler} expandContentWidth={expandContentWidth} />;
     };
     };
     return wideviewMenuItemRenderer;
     return wideviewMenuItemRenderer;
   }, [pageInfo, switchContentWidthClickHandler, expandContentWidth]);
   }, [pageInfo, switchContentWidthClickHandler, expandContentWidth]);
@@ -279,7 +273,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
 
   return (
   return (
     <div className={`${styles['grw-page-controls']} hstack gap-2`} ref={pageControlsRef}>
     <div className={`${styles['grw-page-controls']} hstack gap-2`} ref={pageControlsRef}>
-      { isViewMode && isDeviceLargerThanMd && (
+      { isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
         <SearchButton />
         <SearchButton />
       )}
       )}
 
 
@@ -312,7 +306,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
               bookmarkCount={pageInfo.bookmarkCount}
               bookmarkCount={pageInfo.bookmarkCount}
             />
             />
           )}
           )}
-          {revisionId != null && (
+          {revisionId != null && !isSearchPage && (
             <SeenUserInfo
             <SeenUserInfo
               seenUsers={seenUsers}
               seenUsers={seenUsers}
               sumOfSeenUsers={sumOfSeenUsers}
               sumOfSeenUsers={sumOfSeenUsers}

+ 12 - 9
apps/app/src/components/PageCreateModal.tsx

@@ -12,7 +12,7 @@ import {
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 import { useCreateTemplatePage } from '~/client/services/create-page';
 import { useCreateTemplatePage } from '~/client/services/create-page';
-import { useCreatePageAndTransit } from '~/client/services/create-page/use-create-page-and-transit';
+import { useCreatePage } from '~/client/services/create-page/use-create-page';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { usePageCreateModal } from '~/stores/modal';
@@ -36,7 +36,7 @@ const PageCreateModal: React.FC = () => {
   const path = pageCreateModalData?.path;
   const path = pageCreateModalData?.path;
   const isOpened = pageCreateModalData?.isOpened ?? false;
   const isOpened = pageCreateModalData?.isOpened ?? false;
 
 
-  const { createAndTransit } = useCreatePageAndTransit();
+  const { create } = useCreatePage();
   const { createTemplate } = useCreateTemplatePage();
   const { createTemplate } = useCreateTemplatePage();
 
 
   const { data: isReachable } = useIsSearchServiceReachable();
   const { data: isReachable } = useIsSearchServiceReachable();
@@ -94,25 +94,28 @@ const PageCreateModal: React.FC = () => {
    */
    */
   const createTodayPage = useCallback(async() => {
   const createTodayPage = useCallback(async() => {
     const joinedPath = [todaysParentPath, todayInput].join('/');
     const joinedPath = [todaysParentPath, todayInput].join('/');
-    return createAndTransit(
-      { path: joinedPath, wip: true, origin: Origin.View },
-      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+    return create(
+      {
+        path: joinedPath, parentPath: todaysParentPath, wip: true, origin: Origin.View,
+      },
+      { onTerminated: closeCreateModal },
     );
     );
-  }, [closeCreateModal, createAndTransit, todayInput, todaysParentPath]);
+  }, [closeCreateModal, create, todayInput, todaysParentPath]);
 
 
   /**
   /**
    * access input page
    * access input page
    */
    */
   const createInputPage = useCallback(async() => {
   const createInputPage = useCallback(async() => {
-    return createAndTransit(
+    return create(
       {
       {
         path: pageNameInput,
         path: pageNameInput,
+        parentPath: pathname,
         wip: true,
         wip: true,
         origin: Origin.View,
         origin: Origin.View,
       },
       },
-      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+      { onTerminated: closeCreateModal },
     );
     );
-  }, [closeCreateModal, createAndTransit, pageNameInput]);
+  }, [closeCreateModal, create, pageNameInput, pathname]);
 
 
   /**
   /**
    * access template page
    * access template page

+ 3 - 2
apps/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -3,9 +3,10 @@ import React, {
 } from 'react';
 } from 'react';
 
 
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import {
 import {
-  MergeViewer, CodeMirrorEditorDiff, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated,
-} from '@growi/editor';
+  MergeViewer, CodeMirrorEditorDiff, useCodeMirrorEditorIsolated,
+} from '@growi/editor/dist/client';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';

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

@@ -4,8 +4,7 @@ import React, {
   useMemo,
   useMemo,
 } from 'react';
 } from 'react';
 
 
-import { useCodeMirrorEditorIsolated } from '@growi/editor';
-import { useDrawioModalForEditor } from '@growi/editor/src/stores/use-drawio';
+import { useCodeMirrorEditorIsolated, useDrawioModalForEditor } from '@growi/editor/dist/client';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import {
 import {
   Modal,
   Modal,

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

@@ -1,6 +1,7 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
 
 
-import { useHandsontableModalForEditor } from '@growi/editor/src/stores/use-handsontable';
+import { MarkdownTable } from '@growi/editor';
+import { useHandsontableModalForEditor } from '@growi/editor/dist/client';
 import { HotTable } from '@handsontable/react';
 import { HotTable } from '@handsontable/react';
 import type Handsontable from 'handsontable';
 import type Handsontable from 'handsontable';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -10,7 +11,6 @@ import {
 } from 'reactstrap';
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import MarkdownTable from '~/client/models/MarkdownTable';
 import { replaceFocusedMarkdownTableWithEditor, getMarkdownTable } from '~/components/PageEditor/markdown-table-util-for-editor';
 import { replaceFocusedMarkdownTableWithEditor, getMarkdownTable } from '~/components/PageEditor/markdown-table-util-for-editor';
 import { useHandsontableModal } from '~/stores/modal';
 import { useHandsontableModal } from '~/stores/modal';
 
 

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

@@ -2,8 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react';
 
 
 import path from 'path';
 import path from 'path';
 
 
-import Linker from '@growi/editor/src/services/link-util/Linker';
-import { useLinkEditModal } from '@growi/editor/src/stores/use-link-edit-modal';
+import { Linker } from '@growi/editor';
+import { useLinkEditModal } from '@growi/editor/dist/client';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal,
   Modal,

+ 1 - 1
apps/app/src/components/PageEditor/MarkdownTableDataImportForm.tsx

@@ -1,12 +1,12 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
 
 
+import { MarkdownTable } from '@growi/editor';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Button,
   Button,
   Collapse,
   Collapse,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import MarkdownTable from '~/client/models/MarkdownTable';
 
 
 type MarkdownTableDataImportFormProps = {
 type MarkdownTableDataImportFormProps = {
   onCancel: () => void,
   onCancel: () => void,

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

@@ -9,10 +9,11 @@ import nodePath from 'path';
 
 
 import { type IPageHasId, Origin } from '@growi/core';
 import { type IPageHasId, Origin } from '@growi/core';
 import { pathUtils } from '@growi/core/dist/utils';
 import { pathUtils } from '@growi/core/dist/utils';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import {
 import {
-  CodeMirrorEditorMain, GlobalCodeMirrorEditorKey,
+  CodeMirrorEditorMain,
   useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
   useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
-} from '@growi/editor';
+} from '@growi/editor/dist/client';
 import { useRect } from '@growi/ui/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
 import detectIndent from 'detect-indent';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -68,6 +69,7 @@ declare global {
 export type SaveOptions = {
 export type SaveOptions = {
   wip: boolean,
   wip: boolean,
   slackChannels: string,
   slackChannels: string,
+  isSlackEnabled: boolean,
   overwriteScopesOfDescendants?: boolean
   overwriteScopesOfDescendants?: boolean
 }
 }
 export type Save = (
 export type Save = (

+ 2 - 1
apps/app/src/components/PageEditor/PageEditorReadOnly.tsx

@@ -1,6 +1,7 @@
 import react, { useMemo, useRef } from 'react';
 import react, { useMemo, useRef } from 'react';
 
 
-import { CodeMirrorEditorReadOnly, GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { CodeMirrorEditorReadOnly } from '@growi/editor/dist/client';
 import { throttle } from 'throttle-debounce';
 import { throttle } from 'throttle-debounce';
 
 
 import { useShouldExpandContent } from '~/client/services/layout';
 import { useShouldExpandContent } from '~/client/services/layout';

+ 2 - 1
apps/app/src/components/PageEditor/ScrollSyncHelper.tsx

@@ -1,6 +1,7 @@
 import { useCallback, type RefObject, useRef } from 'react';
 import { useCallback, type RefObject, useRef } from 'react';
 
 
-import { useCodeMirrorEditorIsolated, type GlobalCodeMirrorEditorKey } from '@growi/editor';
+import type { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client';
 
 
 let defaultTop = 0;
 let defaultTop = 0;
 const padding = 5;
 const padding = 5;

+ 2 - 1
apps/app/src/components/PageEditor/conflict.tsx

@@ -2,7 +2,8 @@ import { useCallback, useEffect } from 'react';
 
 
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useGlobalSocket } from '@growi/core/dist/swr';
-import { GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated } from '@growi/editor';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';

+ 1 - 2
apps/app/src/components/PageEditor/markdown-table-util-for-editor.ts

@@ -1,6 +1,5 @@
 import type { EditorView } from '@codemirror/view';
 import type { EditorView } from '@codemirror/view';
-
-import MarkdownTable from '~/client/models/MarkdownTable';
+import { MarkdownTable } from '@growi/editor';
 
 
 // https://regex101.com/r/7BN2fR/10
 // https://regex101.com/r/7BN2fR/10
 const linePartOfTableRE = /^([^\r\n|]*)\|(([^\r\n|]*\|)+)$/;
 const linePartOfTableRE = /^([^\r\n|]*)\|(([^\r\n|]*\|)+)$/;

+ 6 - 6
apps/app/src/components/PrivateLegacyPages.tsx

@@ -348,13 +348,14 @@ const PrivateLegacyPages = (): JSX.Element => {
     );
     );
   }, [t, openConvertModalHandler]);
   }, [t, openConvertModalHandler]);
 
 
-  const searchControlAllAction = useMemo(() => {
+  const extraControls = useMemo(() => {
     const isCheckboxDisabled = hitsCount === 0;
     const isCheckboxDisabled = hitsCount === 0;
 
 
     return (
     return (
-      <div className="search-control d-flex align-items-center">
-        <div className="d-flex ps-md-2">
+      <div className="d-flex align-items-center">
+        <div className="d-flex">
           <OperateAllControl
           <OperateAllControl
+            inputClassName="me-2"
             ref={selectAllControlRef}
             ref={selectAllControlRef}
             isCheckboxDisabled={isCheckboxDisabled}
             isCheckboxDisabled={isCheckboxDisabled}
             onCheckboxChanged={selectAllCheckboxChangedHandler}
             onCheckboxChanged={selectAllCheckboxChangedHandler}
@@ -387,15 +388,14 @@ const PrivateLegacyPages = (): JSX.Element => {
   const searchControl = useMemo(() => {
   const searchControl = useMemo(() => {
     return (
     return (
       <SearchControl
       <SearchControl
-        isSearchServiceReachable
         isEnableSort={false}
         isEnableSort={false}
         isEnableFilter={false}
         isEnableFilter={false}
         initialSearchConditions={{ keyword: initQ, limit: INITIAL_PAGING_SIZE }}
         initialSearchConditions={{ keyword: initQ, limit: INITIAL_PAGING_SIZE }}
         onSearchInvoked={searchInvokedHandler}
         onSearchInvoked={searchInvokedHandler}
-        allControl={searchControlAllAction}
+        extraControls={extraControls}
       />
       />
     );
     );
-  }, [searchInvokedHandler, searchControlAllAction]);
+  }, [searchInvokedHandler, extraControls]);
 
 
   const searchResultListHead = useMemo(() => {
   const searchResultListHead = useMemo(() => {
     if (data == null) {
     if (data == null) {

+ 12 - 10
apps/app/src/components/SavePageControls.tsx

@@ -35,30 +35,32 @@ declare global {
 const logger = loggerFactory('growi:SavePageControls');
 const logger = loggerFactory('growi:SavePageControls');
 
 
 
 
-const SavePageButton = (props: {slackChannels: string, isDeviceLargerThanMd?: boolean}) => {
+const SavePageButton = (props: {slackChannels: string, isSlackEnabled?: boolean, isDeviceLargerThanMd?: boolean}) => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
   const [isSavePageModalShown, setIsSavePageModalShown] = useState<boolean>(false);
   const [isSavePageModalShown, setIsSavePageModalShown] = useState<boolean>(false);
 
 
-  const { slackChannels, isDeviceLargerThanMd } = props;
+  const { slackChannels, isSlackEnabled, isDeviceLargerThanMd } = props;
 
 
   const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
   const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
 
 
   const save = useCallback(async(): Promise<void> => {
   const save = useCallback(async(): Promise<void> => {
     // save
     // save
-    globalEmitter.emit('saveAndReturnToView', { wip: false, slackChannels });
-  }, [slackChannels]);
+    globalEmitter.emit('saveAndReturnToView', { wip: false, slackChannels, isSlackEnabled });
+  }, [isSlackEnabled, slackChannels]);
 
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // save
     // save
-    globalEmitter.emit('saveAndReturnToView', { wip: false, overwriteScopesOfDescendants: true, slackChannels });
-  }, [slackChannels]);
+    globalEmitter.emit('saveAndReturnToView', {
+      wip: false, overwriteScopesOfDescendants: true, slackChannels, isSlackEnabled,
+    });
+  }, [isSlackEnabled, slackChannels]);
 
 
   const saveAndMakeWip = useCallback(() => {
   const saveAndMakeWip = useCallback(() => {
     // save
     // save
-    globalEmitter.emit('saveAndReturnToView', { wip: true, slackChannels });
-  }, [slackChannels]);
+    globalEmitter.emit('saveAndReturnToView', { wip: true, slackChannels, isSlackEnabled });
+  }, [isSlackEnabled, slackChannels]);
 
 
   const labelSubmitButton = t('Update');
   const labelSubmitButton = t('Update');
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
@@ -197,11 +199,11 @@ export const SavePageControls = (): JSX.Element | null => {
               )
               )
             }
             }
 
 
-            <SavePageButton slackChannels={slackChannels} isDeviceLargerThanMd />
+            <SavePageButton isSlackEnabled={isSlackEnabled} slackChannels={slackChannels} isDeviceLargerThanMd />
           </>
           </>
         ) : (
         ) : (
           <>
           <>
-            <SavePageButton slackChannels={slackChannels} />
+            <SavePageButton isSlackEnabled={isSlackEnabled} slackChannels={slackChannels} />
             <button
             <button
               type="button"
               type="button"
               className="btn btn-outline-neutral-secondary border-0 fs-5 p-0 ms-1 text-muted"
               className="btn btn-outline-neutral-secondary border-0 fs-5 p-0 ms-1 text-muted"

+ 10 - 0
apps/app/src/components/SearchPage.module.scss

@@ -0,0 +1,10 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use '@growi/ui/scss/atoms/btn-muted';
+
+.search-page :global {
+  .btn.btn-muted-danger {
+    @include btn-muted.colorize(bs.$danger, bs.$secondary);
+
+    --bs-btn-active-bg: rgba(var(--bs-danger-rgb), 0.3);
+  }
+}

+ 60 - 22
apps/app/src/components/SearchPage.tsx

@@ -7,7 +7,7 @@ import { useTranslation } from 'next-i18next';
 import type { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 import type { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 import { useKeywordManager } from '~/client/services/search-operation';
 import { useKeywordManager } from '~/client/services/search-operation';
 import type { IFormattedSearchResult } from '~/interfaces/search';
 import type { IFormattedSearchResult } from '~/interfaces/search';
-import { useIsSearchServiceReachable, useShowPageLimitationL } from '~/stores/context';
+import { useShowPageLimitationL } from '~/stores/context';
 import { type ISearchConditions, type ISearchConfigurations, useSWRxSearch } from '~/stores/search';
 import { type ISearchConditions, type ISearchConfigurations, useSWRxSearch } from '~/stores/search';
 
 
 import { NotAvailableForGuest } from './NotAvailableForGuest';
 import { NotAvailableForGuest } from './NotAvailableForGuest';
@@ -17,6 +17,7 @@ import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
 import SearchControl from './SearchPage/SearchControl';
 import { type IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage/SearchPageBase';
 import { type IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage/SearchPageBase';
 
 
+import styles from './SearchPage.module.scss';
 
 
 // TODO: replace with "customize:showPageLimitationS"
 // TODO: replace with "customize:showPageLimitationS"
 const INITIAL_PAGIONG_SIZE = 20;
 const INITIAL_PAGIONG_SIZE = 20;
@@ -90,11 +91,12 @@ export const SearchPage = (): JSX.Element => {
   const [offset, setOffset] = useState<number>(0);
   const [offset, setOffset] = useState<number>(0);
   const [limit, setLimit] = useState<number>(showPageLimitationL ?? INITIAL_PAGIONG_SIZE);
   const [limit, setLimit] = useState<number>(showPageLimitationL ?? INITIAL_PAGIONG_SIZE);
   const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({});
   const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({});
+  const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
+  const [selectedCount, setSelectedCount] = useState(0);
+
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
 
 
-  const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
-
   const { data, conditions, mutate } = useSWRxSearch(keyword ?? '', null, {
   const { data, conditions, mutate } = useSWRxSearch(keyword ?? '', null, {
     ...configurationsByControl,
     ...configurationsByControl,
     offset,
     offset,
@@ -108,7 +110,7 @@ export const SearchPage = (): JSX.Element => {
     pushState(newKeyword);
     pushState(newKeyword);
 
 
     mutate();
     mutate();
-  }, [keyword, mutate, pushState]);
+  }, [mutate, pushState]);
 
 
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
     const instance = searchPageBaseRef.current;
     const instance = searchPageBaseRef.current;
@@ -123,6 +125,9 @@ export const SearchPage = (): JSX.Element => {
     else {
     else {
       instance.deselectAll();
       instance.deselectAll();
     }
     }
+
+    // update selected count
+    setSelectedCount(instance.getSelectedPageIds?.().size ?? 0);
   }, []);
   }, []);
 
 
   const selectedPagesByCheckboxesChangedHandler = useCallback((selectedCount: number, totalCount: number) => {
   const selectedPagesByCheckboxesChangedHandler = useCallback((selectedCount: number, totalCount: number) => {
@@ -139,8 +144,12 @@ export const SearchPage = (): JSX.Element => {
       instance.select();
       instance.select();
     }
     }
     else {
     else {
+      setIsCollapsed(true);
       instance.setIndeterminate();
       instance.setIndeterminate();
     }
     }
+
+    // update selected count
+    setSelectedCount(selectedCount);
   }, []);
   }, []);
 
 
   const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
   const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
@@ -166,46 +175,74 @@ export const SearchPage = (): JSX.Element => {
 
 
   const hitsCount = data?.meta.hitsCount;
   const hitsCount = data?.meta.hitsCount;
 
 
-  const allControl = useMemo(() => {
-    const isDisabled = hitsCount === 0;
-
+  const extraControls = useMemo(() => {
     return (
     return (
       <NotAvailableForGuest>
       <NotAvailableForGuest>
         <NotAvailableForReadOnlyUser>
         <NotAvailableForReadOnlyUser>
-          <OperateAllControl
-            ref={selectAllControlRef}
-            isCheckboxDisabled={isDisabled}
-            onCheckboxChanged={selectAllCheckboxChangedHandler}
+          <button
+            type="button"
+            className={`${isCollapsed ? 'active' : ''} btn btn-muted-danger d-flex align-items-center ms-2`}
+            aria-expanded="false"
+            onClick={() => { setIsCollapsed(!isCollapsed) }}
           >
           >
+            <span className="material-symbols-outlined fs-5">delete</span>
+            <span className={`material-symbols-outlined me-1 ${isCollapsed ? 'rotate-180' : ''}`}>keyboard_arrow_down</span>
+          </button>
+        </NotAvailableForReadOnlyUser>
+      </NotAvailableForGuest>
+    );
+  }, [isCollapsed]);
+
+  const collapseContents = useMemo(() => {
+    return (
+      <NotAvailableForGuest>
+        <NotAvailableForReadOnlyUser>
+          <div className="d-flex align-items-center py-2">
+            <div className="ms-4">
+              <OperateAllControl
+                inputId="cb-select-all"
+                inputClassName="form-check-input"
+                ref={selectAllControlRef}
+                isCheckboxDisabled={hitsCount === 0}
+                onCheckboxChanged={selectAllCheckboxChangedHandler}
+              >
+                <label
+                  className="form-check-label ms-2"
+                  htmlFor="cb-select-all"
+                >
+                  {t('search_result.select_all')}
+                </label>
+              </OperateAllControl>
+            </div>
+
             <button
             <button
               type="button"
               type="button"
-              className="btn border-0 text-danger"
-              disabled={isDisabled}
+              className="ms-3 open-delete-modal-button btn btn-outline-danger d-flex align-items-center"
+              disabled={selectedCount === 0}
               onClick={deleteAllButtonClickedHandler}
               onClick={deleteAllButtonClickedHandler}
             >
             >
-              {t('search_result.delete_all_selected_page')}
+              <span className="material-symbols-outlined fs-5">delete</span>{t('search_result.delete_selected_pages')}
             </button>
             </button>
-          </OperateAllControl>
+          </div>
         </NotAvailableForReadOnlyUser>
         </NotAvailableForReadOnlyUser>
       </NotAvailableForGuest>
       </NotAvailableForGuest>
     );
     );
-  }, [deleteAllButtonClickedHandler, hitsCount, selectAllCheckboxChangedHandler, t]);
+  }, [deleteAllButtonClickedHandler, hitsCount, selectAllCheckboxChangedHandler, selectedCount, t]);
+
 
 
   const searchControl = useMemo(() => {
   const searchControl = useMemo(() => {
-    if (!isSearchServiceReachable) {
-      return <></>;
-    }
     return (
     return (
       <SearchControl
       <SearchControl
-        isSearchServiceReachable={isSearchServiceReachable}
         isEnableSort
         isEnableSort
         isEnableFilter
         isEnableFilter
         initialSearchConditions={initialSearchConditions}
         initialSearchConditions={initialSearchConditions}
         onSearchInvoked={searchInvokedHandler}
         onSearchInvoked={searchInvokedHandler}
-        allControl={allControl}
+        extraControls={extraControls}
+        collapseContents={collapseContents}
+        isCollapsed={isCollapsed}
       />
       />
     );
     );
-  }, [allControl, initialSearchConditions, isSearchServiceReachable, searchInvokedHandler]);
+  }, [extraControls, collapseContents, initialSearchConditions, isCollapsed, searchInvokedHandler]);
 
 
   const searchResultListHead = useMemo(() => {
   const searchResultListHead = useMemo(() => {
     if (data == null) {
     if (data == null) {
@@ -241,6 +278,7 @@ export const SearchPage = (): JSX.Element => {
 
 
   return (
   return (
     <SearchPageBase
     <SearchPageBase
+      className={styles['search-page']}
       ref={searchPageBaseRef}
       ref={searchPageBaseRef}
       pages={data?.data}
       pages={data?.data}
       searchingKeyword={keyword}
       searchingKeyword={keyword}

+ 6 - 2
apps/app/src/components/SearchPage/OperateAllControl.tsx

@@ -7,6 +7,8 @@ import type { ISelectableAndIndeterminatable } from '~/client/interfaces/selecta
 import type { IndeterminateInputElement } from '~/interfaces/indeterminate-input-elm';
 import type { IndeterminateInputElement } from '~/interfaces/indeterminate-input-elm';
 
 
 type Props = {
 type Props = {
+  inputId?: string,
+  inputClassName?: string,
   isCheckboxDisabled?: boolean,
   isCheckboxDisabled?: boolean,
   onCheckboxChanged?: (isChecked: boolean) => void,
   onCheckboxChanged?: (isChecked: boolean) => void,
   children?: React.ReactNode,
   children?: React.ReactNode,
@@ -14,6 +16,8 @@ type Props = {
 
 
 const OperateAllControlSubstance: ForwardRefRenderFunction<ISelectableAndIndeterminatable, Props> = (props: Props, ref): JSX.Element => {
 const OperateAllControlSubstance: ForwardRefRenderFunction<ISelectableAndIndeterminatable, Props> = (props: Props, ref): JSX.Element => {
   const {
   const {
+    inputId,
+    inputClassName = '',
     isCheckboxDisabled,
     isCheckboxDisabled,
     onCheckboxChanged,
     onCheckboxChanged,
     children,
     children,
@@ -54,10 +58,10 @@ const OperateAllControlSubstance: ForwardRefRenderFunction<ISelectableAndIndeter
   return (
   return (
     <div className="d-flex align-items-center">
     <div className="d-flex align-items-center">
       <Input
       <Input
+        id={inputId}
         type="checkbox"
         type="checkbox"
-        id="cb-check-all"
         data-testid="cb-select-all"
         data-testid="cb-select-all"
-        className="ms-2"
+        className={inputClassName}
         innerRef={selectAllCheckboxElm}
         innerRef={selectAllCheckboxElm}
         disabled={isCheckboxDisabled}
         disabled={isCheckboxDisabled}
         onChange={checkboxChangedHandler}
         onChange={checkboxChangedHandler}

+ 16 - 24
apps/app/src/components/SearchPage/SearchControl.tsx

@@ -3,6 +3,7 @@ import React, {
 } from 'react';
 } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { Collapse } from 'reactstrap';
 
 
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import type { ISearchConditions, ISearchConfigurations } from '~/stores/search';
 import type { ISearchConditions, ISearchConfigurations } from '~/stores/search';
@@ -14,25 +15,28 @@ import SortControl from './SortControl';
 import styles from './SearchControl.module.scss';
 import styles from './SearchControl.module.scss';
 
 
 type Props = {
 type Props = {
-  isSearchServiceReachable: boolean,
   isEnableSort: boolean,
   isEnableSort: boolean,
   isEnableFilter: boolean,
   isEnableFilter: boolean,
   initialSearchConditions: Partial<ISearchConditions>,
   initialSearchConditions: Partial<ISearchConditions>,
 
 
   onSearchInvoked?: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
   onSearchInvoked?: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
 
 
-  allControl: React.ReactNode,
+  extraControls: React.ReactNode,
+
+  collapseContents?: React.ReactNode,
+  isCollapsed?: boolean,
 }
 }
 
 
 const SearchControl = React.memo((props: Props): JSX.Element => {
 const SearchControl = React.memo((props: Props): JSX.Element => {
 
 
   const {
   const {
-    isSearchServiceReachable,
     isEnableSort,
     isEnableSort,
     isEnableFilter,
     isEnableFilter,
     initialSearchConditions,
     initialSearchConditions,
     onSearchInvoked,
     onSearchInvoked,
-    allControl,
+    extraControls,
+    collapseContents,
+    isCollapsed,
   } = props;
   } = props;
 
 
   const keywordOnInit = initialSearchConditions.keyword ?? '';
   const keywordOnInit = initialSearchConditions.keyword ?? '';
@@ -46,14 +50,6 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
 
 
   const { t } = useTranslation('');
   const { t } = useTranslation('');
 
 
-  const searchFormSubmittedHandler = useCallback((input: string) => {
-    setKeyword(input);
-
-    onSearchInvoked?.(input, {
-      sort, order, includeUserPages, includeTrashPages,
-    });
-  }, [includeTrashPages, includeUserPages, onSearchInvoked, order, sort]);
-
   const changeSortHandler = useCallback((nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => {
   const changeSortHandler = useCallback((nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => {
     setSort(nextSort);
     setSort(nextSort);
     setOrder(nextOrder);
     setOrder(nextOrder);
@@ -156,20 +152,16 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
             </div>
             </div>
           </>
           </>
         )}
         )}
-        <div className="d-flex">
-          <div className="btn-group">
-            {/* TODO: imprv to delete all result UI */}
-            {/* <button className={`btn btn-sm rounded ${styles['btn-delete']}`} type="button" data-bs-toggle="dropdown" aria-expanded="false">
-              <span className="material-symbols-outlined ">delete</span>
-              <span className="material-symbols-outlined ">expand_more</span>
-            </button> */}
-            {/* <ul className="dropdown-menu"> */}
-            {allControl}
-            {/* </ul> */}
-          </div>
-        </div>
+
+        {extraControls}
       </div>
       </div>
 
 
+      { collapseContents != null && (
+        <Collapse isOpen={isCollapsed}>
+          {collapseContents}
+        </Collapse>
+      ) }
+
       <SearchOptionModal
       <SearchOptionModal
         isOpen={isFileterOptionModalShown || false}
         isOpen={isFileterOptionModalShown || false}
         onClose={() => setIsFileterOptionModalShown(false)}
         onClose={() => setIsFileterOptionModalShown(false)}

+ 13 - 7
apps/app/src/components/SearchPage/SearchModalTriggerinput.tsx

@@ -18,14 +18,20 @@ export const SearchModalTriggerinput: React.FC<Props> = (props: Props) => {
   }, [openSearchModal, keywordOnInit]);
   }, [openSearchModal, keywordOnInit]);
 
 
   return (
   return (
-    <div>
-      <input
-        className="form-control"
-        type="input"
-        value={keywordOnInit}
+    <div className="d-flex align-items-center">
+      <span className="text-secondary material-symbols-outlined fs-4 me-2">search</span>
+      <form
+        className="w-100 position-relative"
         onClick={inputClickHandler}
         onClick={inputClickHandler}
-        readOnly
-      />
+      >
+        <input
+          className="form-control"
+          type="input"
+          value={keywordOnInit}
+          onClick={inputClickHandler}
+          readOnly
+        />
+      </form>
     </div>
     </div>
   );
   );
 };
 };

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

@@ -35,6 +35,7 @@ export interface IReturnSelectedPageIds {
 
 
 
 
 type Props = {
 type Props = {
+  className?: string,
   pages?: IPageWithSearchMeta[],
   pages?: IPageWithSearchMeta[],
   searchingKeyword?: string,
   searchingKeyword?: string,
 
 
@@ -54,6 +55,7 @@ const SearchResultContent = dynamic(() => import('./SearchResultContent').then(m
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
 
 
   const {
   const {
+    className,
     pages,
     pages,
     searchingKeyword,
     searchingKeyword,
     forceHideMenuItems,
     forceHideMenuItems,
@@ -171,7 +173,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
     : undefined;
     : undefined;
 
 
   return (
   return (
-    <div className="search-result-base flex-grow-1 d-flex flex-expand-vh-100" data-testid="search-result-base">
+    <div className={`${className ?? ''} search-result-base flex-grow-1 d-flex flex-expand-vh-100`} data-testid="search-result-base">
 
 
       <div className="flex-expand-vert border boder-gray search-result-list" id="search-result-list">
       <div className="flex-expand-vert border boder-gray search-result-list" id="search-result-list">
 
 

+ 1 - 1
apps/app/src/components/SearchPage/SearchResultContent.tsx

@@ -188,7 +188,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       : undefined;
       : undefined;
 
 
     return (
     return (
-      <div className="d-flex flex-column align-items-end justify-content-center px-2 py-1">
+      <div className="d-flex flex-column flex-row-reverse flex px-2 py-1">
         <PageControls
         <PageControls
           pageId={page._id}
           pageId={page._id}
           revisionId={revisionId}
           revisionId={revisionId}

+ 4 - 4
apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx

@@ -3,16 +3,16 @@ import { useCallback } from 'react';
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 
 
 export const SidebarNotFound = (): JSX.Element => {
 export const SidebarNotFound = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { createAndTransit } = useCreatePageAndTransit();
+  const { create } = useCreatePage();
 
 
   const clickCreateButtonHandler = useCallback(async() => {
   const clickCreateButtonHandler = useCallback(async() => {
-    createAndTransit({ path: '/Sidebar', wip: false, origin: Origin.View });
-  }, [createAndTransit]);
+    create({ path: '/Sidebar', wip: false, origin: Origin.View }, { skipPageExistenceCheck: true });
+  }, [create]);
 
 
   return (
   return (
     <div>
     <div>

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

@@ -2,7 +2,7 @@ import { useCallback } from 'react';
 
 
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
 
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
 
 
 
 
@@ -14,20 +14,23 @@ type UseCreateNewPage = () => {
 export const useCreateNewPage: UseCreateNewPage = () => {
 export const useCreateNewPage: UseCreateNewPage = () => {
   const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
   const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
 
 
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
 
 
   const createNewPage = useCallback(async() => {
   const createNewPage = useCallback(async() => {
     if (isLoadingPagePath) return;
     if (isLoadingPagePath) return;
 
 
-    return createAndTransit(
+    return create(
       {
       {
         parentPath: currentPagePath,
         parentPath: currentPagePath,
         optionalParentPath: '/',
         optionalParentPath: '/',
         wip: true,
         wip: true,
         origin: Origin.View,
         origin: Origin.View,
       },
       },
+      {
+        skipPageExistenceCheck: true,
+      },
     );
     );
-  }, [createAndTransit, currentPagePath, isLoadingPagePath]);
+  }, [create, currentPagePath, isLoadingPagePath]);
 
 
   return {
   return {
     isCreating,
     isCreating,

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

@@ -5,7 +5,7 @@ import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import { format } from 'date-fns/format';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 
 
 
 
@@ -19,24 +19,26 @@ export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
 
 
   const isCreatable = currentUser != null;
   const isCreatable = currentUser != null;
 
 
   const parentDirName = t('create_page_dropdown.todays.memo');
   const parentDirName = t('create_page_dropdown.todays.memo');
   const now = format(new Date(), 'yyyy/MM/dd');
   const now = format(new Date(), 'yyyy/MM/dd');
+  const parentPath = `${userHomepagePath(currentUser)}/${parentDirName}`;
   const todaysPath = isCreatable
   const todaysPath = isCreatable
-    ? `${userHomepagePath(currentUser)}/${parentDirName}/${now}`
+    ? `${parentPath}/${now}`
     : null;
     : null;
 
 
   const createTodaysMemo = useCallback(async() => {
   const createTodaysMemo = useCallback(async() => {
     if (!isCreatable || todaysPath == null) return;
     if (!isCreatable || todaysPath == null) return;
 
 
-    return createAndTransit(
-      { path: todaysPath, wip: true, origin: Origin.View },
-      { shouldCheckPageExists: true },
+    return create(
+      {
+        path: todaysPath, parentPath, wip: true, origin: Origin.View,
+      },
     );
     );
-  }, [createAndTransit, isCreatable, todaysPath]);
+  }, [create, isCreatable, todaysPath, parentPath]);
 
 
   return {
   return {
     isCreating,
     isCreating,

+ 6 - 4
apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -54,14 +54,16 @@ export const PageTreeHeader = memo(({ isWipPageShown, onWipPageShownChange }: He
         </button>
         </button>
 
 
         <ul className="dropdown-menu">
         <ul className="dropdown-menu">
-          <li className="dropdown-item" onClick={onWipPageShownChange}>
-            <div className="form-check form-switch">
+          <li className="dropdown-item">
+            <div className="form-check form-switch flex-fill d-flex">
               <input
               <input
+                id="show-wip-page-checkbox"
                 className="form-check-input"
                 className="form-check-input"
                 type="checkbox"
                 type="checkbox"
-                checked={isWipPageShown}
+                defaultChecked={isWipPageShown}
+                onChange={onWipPageShownChange}
               />
               />
-              <label className="form-label form-check-label text-muted mb-0">
+              <label className="form-check-label flex-grow-1 ms-2" htmlFor="show-wip-page-checkbox">
                 {t('sidebar_header.show_wip_page')}
                 {t('sidebar_header.show_wip_page')}
               </label>
               </label>
             </div>
             </div>

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

@@ -5,7 +5,7 @@ import React, {
 import assert from 'assert';
 import assert from 'assert';
 
 
 import type { Lang } from '@growi/core';
 import type { Lang } from '@growi/core';
-import { useTemplateModal, type TemplateModalStatus } from '@growi/editor/src/stores/use-template-modal';
+import { useTemplateModal, type TemplateModalStatus } from '@growi/editor/dist/client';
 import {
 import {
   extractSupportedLocales, getLocalizedTemplate, type TemplateSummary,
   extractSupportedLocales, getLocalizedTemplate, type TemplateSummary,
 } from '@growi/pluginkit/dist/v4';
 } from '@growi/pluginkit/dist/v4';

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

@@ -11,7 +11,7 @@ import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import { createPage } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
 import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
@@ -60,6 +60,7 @@ export const useNewPageInput = (): UseNewPageInput => {
   const Input: FC<TreeItemToolProps> = (props) => {
   const Input: FC<TreeItemToolProps> = (props) => {
 
 
     const { t } = useTranslation();
     const { t } = useTranslation();
+    const { create: createPage } = useCreatePage();
 
 
     const { itemNode, stateHandlers, isEnableActions } = props;
     const { itemNode, stateHandlers, isEnableActions } = props;
     const { page, children } = itemNode;
     const { page, children } = itemNode;
@@ -107,23 +108,30 @@ export const useNewPageInput = (): UseNewPageInput => {
       setShowInput(false);
       setShowInput(false);
 
 
       try {
       try {
-        await createPage({
-          path: newPagePath,
-          body: undefined,
-          // keep grant info undefined to inherit from parent
-          grant: undefined,
-          grantUserGroupIds: undefined,
-          origin: Origin.View,
-          wip: shouldCreateWipPage(newPagePath),
-        });
-
-        mutatePageTree();
-
-        if (!hasDescendants) {
-          stateHandlers?.setIsOpen(true);
-        }
-
-        toastSuccess(t('successfully_saved_the_page'));
+        await createPage(
+          {
+            path: newPagePath,
+            parentPath,
+            body: undefined,
+            // keep grant info undefined to inherit from parent
+            grant: undefined,
+            grantUserGroupIds: undefined,
+            origin: Origin.View,
+            wip: shouldCreateWipPage(newPagePath),
+          },
+          {
+            skipTransition: true,
+            onCreated: () => {
+              mutatePageTree();
+
+              if (!hasDescendants) {
+                stateHandlers?.setIsOpen(true);
+              }
+
+              toastSuccess(t('successfully_saved_the_page'));
+            },
+          },
+        );
       }
       }
       catch (err) {
       catch (err) {
         toastError(err);
         toastError(err);
@@ -131,7 +139,7 @@ export const useNewPageInput = (): UseNewPageInput => {
       finally {
       finally {
         setProcessingSubmission(false);
         setProcessingSubmission(false);
       }
       }
-    }, [cancel, hasDescendants, page.path, stateHandlers, t]);
+    }, [cancel, hasDescendants, page.path, stateHandlers, t, createPage]);
 
 
     const inputContainerClass = newPageInputStyles['new-page-input-container'] ?? '';
     const inputContainerClass = newPageInputStyles['new-page-input-container'] ?? '';
     const isInvalid = validationResult != null;
     const isInvalid = validationResult != null;

+ 1 - 1
apps/app/src/components/TreeItem/TreeItemLayout.tsx

@@ -243,7 +243,7 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
             };
             };
 
 
             return (
             return (
-              <ItemClassFixed {...itemProps} />
+              <ItemClassFixed key={node.page._id} {...itemProps} />
             );
             );
           }) }
           }) }
 
 

+ 27 - 4
apps/app/src/features/growi-plugin/server/models/vo/github-url.ts

@@ -2,8 +2,12 @@ import sanitize from 'sanitize-filename';
 
 
 // https://regex101.com/r/fK2rV3/1
 // https://regex101.com/r/fK2rV3/1
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
-// https://regex101.com/r/YhZVsj/1
-const sanitizeChars = new RegExp(/[^a-zA-Z_.]+/g);
+// https://regex101.com/r/CQjSuz/1
+const sanitizeBranchChars = new RegExp(/[^a-zA-Z0-9_.]+/g);
+
+// https://regex101.com/r/f4wj8q/1
+// GitHub will return a zip file with the v removed if the tag or branch name is "v + number"
+const checkVersionName = new RegExp(/^v[\d]/g);
 
 
 export class GitHubUrl {
 export class GitHubUrl {
 
 
@@ -13,6 +17,8 @@ export class GitHubUrl {
 
 
   private _branchName: string;
   private _branchName: string;
 
 
+  private _tagName: string;
+
   get organizationName(): string {
   get organizationName(): string {
     return this._organizationName;
     return this._organizationName;
   }
   }
@@ -25,17 +31,33 @@ export class GitHubUrl {
     return this._branchName;
     return this._branchName;
   }
   }
 
 
+  get tagName(): string {
+    return this._tagName;
+  }
+
   get archiveUrl(): string {
   get archiveUrl(): string {
     const encodedBranchName = encodeURIComponent(this.branchName);
     const encodedBranchName = encodeURIComponent(this.branchName);
+    const encodedTagName = encodeURIComponent(this.tagName);
+    if (encodedTagName !== '') {
+      const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/tags/${encodedTagName}.zip`, 'https://github.com');
+      return ghUrl.toString();
+    }
+
     const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/heads/${encodedBranchName}.zip`, 'https://github.com');
     const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/heads/${encodedBranchName}.zip`, 'https://github.com');
     return ghUrl.toString();
     return ghUrl.toString();
+
   }
   }
 
 
   get extractedArchiveDirName(): string {
   get extractedArchiveDirName(): string {
-    return this._branchName.replaceAll(sanitizeChars, '-');
+    if (this._tagName !== '') {
+      const tagName = this._tagName?.match(checkVersionName) ? this._tagName.replace('v', '') : this._tagName;
+      return tagName.replaceAll(sanitizeBranchChars, '-');
+    }
+    const branchName = this._branchName?.match(checkVersionName) ? this._branchName.replace('v', '') : this._branchName;
+    return branchName.replaceAll(sanitizeBranchChars, '-');
   }
   }
 
 
-  constructor(url: string, branchName = 'main') {
+  constructor(url: string, branchName = 'main', tagName = '') {
 
 
     let matched;
     let matched;
     try {
     try {
@@ -52,6 +74,7 @@ export class GitHubUrl {
     }
     }
 
 
     this._branchName = branchName;
     this._branchName = branchName;
+    this._tagName = tagName;
 
 
     this._organizationName = sanitize(matched[1]);
     this._organizationName = sanitize(matched[1]);
     this._reposName = sanitize(matched[2]);
     this._reposName = sanitize(matched[2]);

+ 7 - 7
apps/app/src/interfaces/page-grant.ts

@@ -1,9 +1,9 @@
-import { PageGrant, GroupType } from '@growi/core';
+import type { PageGrant, GroupType } from '@growi/core';
 
 
-import { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
-import { UserGroupDocument } from '~/server/models/user-group';
+import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
+import type { UserGroupDocument } from '~/server/models/user-group';
 
 
-import { IPageGrantData } from './page';
+import type { IPageGrantData } from './page';
 
 
 
 
 type UserGroupType = typeof GroupType.userGroup;
 type UserGroupType = typeof GroupType.userGroup;
@@ -18,12 +18,12 @@ export type IRecordApplicableGrant = Partial<Record<PageGrant, IDataApplicableGr
 export type IResApplicableGrant = {
 export type IResApplicableGrant = {
   data?: IRecordApplicableGrant
   data?: IRecordApplicableGrant
 }
 }
-export type IResIsGrantNormalizedGrantData = {
+export type IResGrantData = {
   isForbidden: boolean,
   isForbidden: boolean,
   currentPageGrant: IPageGrantData,
   currentPageGrant: IPageGrantData,
   parentPageGrant?: IPageGrantData
   parentPageGrant?: IPageGrantData
 }
 }
-export type IResIsGrantNormalized = {
+export type IResCurrentGrantData = {
   isGrantNormalized: boolean,
   isGrantNormalized: boolean,
-  grantData: IResIsGrantNormalizedGrantData
+  grantData: IResGrantData
 };
 };

+ 1 - 0
apps/app/src/interfaces/page.ts

@@ -70,6 +70,7 @@ export type IOptionsForUpdate = {
 export type IOptionsForCreate = {
 export type IOptionsForCreate = {
   grant?: PageGrant,
   grant?: PageGrant,
   grantUserGroupIds?: IGrantedGroup[],
   grantUserGroupIds?: IGrantedGroup[],
+  onlyInheritUserRelatedGrantedGroups?: boolean,
   overwriteScopesOfDescendants?: boolean,
   overwriteScopesOfDescendants?: boolean,
 
 
   origin?: Origin
   origin?: Origin

+ 1 - 1
apps/app/src/pages/[[...path]].page.tsx

@@ -500,7 +500,7 @@ async function injectRoutingInformation(context: GetServerSidePropsContext, prop
     }
     }
 
 
     if (!props.skipSSR) {
     if (!props.skipSSR) {
-      props.yjsData = await crowi.pageService.getYjsData(page._id);
+      props.yjsData = await crowi.pageService.getYjsData(page._id.toString());
     }
     }
   }
   }
 }
 }

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

@@ -77,7 +77,7 @@ export interface PageModel extends Model<PageDocument> {
   generateGrantCondition(
   generateGrantCondition(
     user, userGroups: string[] | null, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
     user, userGroups: string[] | null, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   ): { $or: any[] }
-  findNonEmptyClosestAncestor(path: string): Promise<PageDocument | undefined>
+  findNonEmptyClosestAncestor(path: string): Promise<PageDocument | null>
   findNotEmptyParentByPathRecursively(path: string): Promise<PageDocument | undefined>
   findNotEmptyParentByPathRecursively(path: string): Promise<PageDocument | undefined>
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
   findTemplate(path: string): Promise<{
   findTemplate(path: string): Promise<{
@@ -1027,10 +1027,10 @@ function generateGrantConditionForSystemDeletion(): { $or: any[] } {
 
 
 schema.statics.generateGrantConditionForSystemDeletion = generateGrantConditionForSystemDeletion;
 schema.statics.generateGrantConditionForSystemDeletion = generateGrantConditionForSystemDeletion;
 
 
-// find ancestor page with isEmpty: false. If parameter path is '/', return undefined
-schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promise<PageDocument | undefined> {
+// find ancestor page with isEmpty: false. If parameter path is '/', return null
+schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promise<PageDocument | null> {
   if (path === '/') {
   if (path === '/') {
-    return;
+    return null;
   }
   }
 
 
   const builderForAncestors = new PageQueryBuilder(this.find(), false); // empty page not included
   const builderForAncestors = new PageQueryBuilder(this.find(), false); // empty page not included
@@ -1041,7 +1041,7 @@ schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promi
     .query
     .query
     .exec();
     .exec();
 
 
-  return ancestors[0];
+  return ancestors[0] ?? null;
 };
 };
 
 
 schema.statics.removeGroupsToDeleteFromPages = async function(pages: PageDocument[], groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[]) {
 schema.statics.removeGroupsToDeleteFromPages = async function(pages: PageDocument[], groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[]) {

+ 8 - 5
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -107,14 +107,15 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
   // define validators for req.body
   // define validators for req.body
   const validator: ValidationChain[] = [
   const validator: ValidationChain[] = [
     body('path').optional().not().isEmpty({ ignore_whitespace: true })
     body('path').optional().not().isEmpty({ ignore_whitespace: true })
-      .withMessage("The empty value is not allowd for the 'path'"),
+      .withMessage("Empty value is not allowed for 'path'"),
     body('parentPath').optional().not().isEmpty({ ignore_whitespace: true })
     body('parentPath').optional().not().isEmpty({ ignore_whitespace: true })
-      .withMessage("The empty value is not allowd for the 'parentPath'"),
+      .withMessage("Empty value is not allowed for 'parentPath'"),
     body('optionalParentPath').optional().not().isEmpty({ ignore_whitespace: true })
     body('optionalParentPath').optional().not().isEmpty({ ignore_whitespace: true })
-      .withMessage("The empty value is not allowd for the 'optionalParentPath'"),
+      .withMessage("Empty value is not allowed for 'optionalParentPath'"),
     body('body').optional().isString()
     body('body').optional().isString()
       .withMessage('body must be string or undefined'),
       .withMessage('body must be string or undefined'),
     body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
     body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('onlyInheritUserRelatedGrantedGroups').optional().isBoolean().withMessage('onlyInheritUserRelatedGrantedGroups must be boolean'),
     body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
     body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
     body('pageTags').optional().isArray().withMessage('pageTags must be array'),
     body('pageTags').optional().isArray().withMessage('pageTags must be array'),
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
@@ -231,10 +232,12 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
       let createdPage;
       let createdPage;
       try {
       try {
         const {
         const {
-          grant, grantUserGroupIds, overwriteScopesOfDescendants, wip, origin,
+          grant, grantUserGroupIds, onlyInheritUserRelatedGrantedGroups, overwriteScopesOfDescendants, wip, origin,
         } = req.body;
         } = req.body;
 
 
-        const options: IOptionsForCreate = { overwriteScopesOfDescendants, wip, origin };
+        const options: IOptionsForCreate = {
+          onlyInheritUserRelatedGrantedGroups, overwriteScopesOfDescendants, wip, origin,
+        };
         if (grant != null) {
         if (grant != null) {
           options.grant = grant;
           options.grant = grant;
           options.grantUserGroupIds = grantUserGroupIds;
           options.grantUserGroupIds = grantUserGroupIds;

+ 44 - 4
apps/app/src/server/routes/apiv3/page/index.ts

@@ -2,10 +2,11 @@ import path from 'path';
 
 
 import type { IPage } from '@growi/core';
 import type { IPage } from '@growi/core';
 import {
 import {
-  AllSubscriptionStatusType, SubscriptionStatusType,
+  AllSubscriptionStatusType, PageGrant, SubscriptionStatusType,
 } from '@growi/core';
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import sanitize from 'sanitize-filename';
 import sanitize from 'sanitize-filename';
 
 
@@ -22,6 +23,8 @@ import type { IPageGrantService } from '~/server/service/page-grant';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
 import { createPageHandlersFactory } from './create-page';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
@@ -184,7 +187,7 @@ module.exports = (crowi) => {
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const { Page } = crowi.models;
+  const Page = mongoose.model<IPage, PageModel>('Page');
   const { pageService, exportService } = crowi;
   const { pageService, exportService } = crowi;
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
@@ -202,9 +205,12 @@ module.exports = (crowi) => {
     info: [
     info: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
     ],
-    isGrantNormalized: [
+    getGrantData: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
     ],
+    nonUserRelatedGroupsGranted: [
+      query('path').isString(),
+    ],
     applicableGrant: [
     applicableGrant: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
     ],
@@ -567,7 +573,7 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
-  router.get('/grant-data', loginRequiredStrictly, validator.isGrantNormalized, apiV3FormValidator, async(req, res) => {
+  router.get('/grant-data', loginRequiredStrictly, validator.getGrantData, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.query;
     const { pageId } = req.query;
 
 
     const Page = mongoose.model<IPage, PageModel>('Page');
     const Page = mongoose.model<IPage, PageModel>('Page');
@@ -635,6 +641,40 @@ module.exports = (crowi) => {
     return res.apiv3({ isGrantNormalized, grantData });
     return res.apiv3({ isGrantNormalized, grantData });
   });
   });
 
 
+  // Check if non user related groups are granted page access.
+  // If specified page does not exist, check the closest ancestor.
+  router.get('/non-user-related-groups-granted', loginRequiredStrictly, validator.nonUserRelatedGroupsGranted, apiV3FormValidator,
+    async(req, res: ApiV3Response) => {
+      const { user } = req;
+      const path = normalizePath(req.query.path);
+      const pageGrantService = crowi.pageGrantService as IPageGrantService;
+      try {
+        const page = await Page.findByPath(path, true) ?? await Page.findNonEmptyClosestAncestor(path);
+        if (page == null) {
+          // 'page' should always be non empty, since every page stems back to root page.
+          // If it is empty, there is a problem with the server logic.
+          return res.apiv3Err(new ErrorV3('No page on the page tree could be retrived.', 'page_could_not_be_retrieved'), 500);
+        }
+
+        const userRelatedGroups = await pageGrantService.getUserRelatedGroups(user);
+        const isUserGrantedPageAccess = await pageGrantService.isUserGrantedPageAccess(page, user, userRelatedGroups);
+        if (!isUserGrantedPageAccess) {
+          return res.apiv3Err(new ErrorV3('Cannot access page or ancestor.', 'cannot_access_page'), 403);
+        }
+
+        if (page.grant !== PageGrant.GRANT_USER_GROUP) {
+          return res.apiv3({ isNonUserRelatedGroupsGranted: false });
+        }
+
+        const nonUserRelatedGrantedGroups = await pageGrantService.getNonUserRelatedGrantedGroups(page, user);
+        return res.apiv3({ isNonUserRelatedGroupsGranted: nonUserRelatedGrantedGroups.length > 0 });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
   router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
   router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.query;
     const { pageId } = req.query;
 
 

+ 10 - 7
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -69,7 +69,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       .isEmpty({ ignore_whitespace: true })
       .isEmpty({ ignore_whitespace: true })
       .withMessage("'revisionId' must be specified"),
       .withMessage("'revisionId' must be specified"),
     body('body').exists().isString()
     body('body').exists().isString()
-      .withMessage("The empty value is not allowd for the 'body'"),
+      .withMessage("Empty value is not allowed for 'body'"),
     body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
     body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
     body('userRelatedGrantUserGroupIds').optional().isArray().withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
     body('userRelatedGrantUserGroupIds').optional().isArray().withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
     body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
     body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
@@ -80,7 +80,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
   ];
   ];
 
 
 
 
-  async function postAction(req: UpdatePageRequest, res: ApiV3Response, updatedPage: PageDocument) {
+  async function postAction(req: UpdatePageRequest, res: ApiV3Response, updatedPage: PageDocument, previousRevision: IRevisionHasId | null) {
     // Reflect the updates in ydoc
     // Reflect the updates in ydoc
     const origin = req.body.origin;
     const origin = req.body.origin;
     if (origin === Origin.View || origin === undefined) {
     if (origin === Origin.View || origin === undefined) {
@@ -111,10 +111,10 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     }
     }
 
 
     // user notification
     // user notification
-    const { revisionId, isSlackEnabled, slackChannels } = req.body;
+    const { isSlackEnabled, slackChannels } = req.body;
     if (isSlackEnabled) {
     if (isSlackEnabled) {
       try {
       try {
-        const option = revisionId != null ? { previousRevision: revisionId } : undefined;
+        const option = previousRevision != null ? { previousRevision } : undefined;
         const results = await crowi.userNotificationService.fire(updatedPage, req.user, slackChannels, 'update', option);
         const results = await crowi.userNotificationService.fire(updatedPage, req.user, slackChannels, 'update', option);
         results.forEach((result) => {
         results.forEach((result) => {
           if (result.status === 'rejected') {
           if (result.status === 'rejected') {
@@ -138,6 +138,8 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
         pageId, revisionId, body, origin,
         pageId, revisionId, body, origin,
       } = req.body;
       } = req.body;
 
 
+      const sanitizeRevisionId = revisionId == null ? undefined : xss.process(revisionId);
+
       // check page existence
       // check page existence
       const isExist = await Page.count({ _id: pageId }) > 0;
       const isExist = await Page.count({ _id: pageId }) > 0;
       if (!isExist) {
       if (!isExist) {
@@ -147,7 +149,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       // check revision
       // check revision
       const currentPage = await Page.findByIdAndViewer(pageId, req.user);
       const currentPage = await Page.findByIdAndViewer(pageId, req.user);
 
 
-      if (currentPage != null && !await currentPage.isUpdatable(revisionId, origin)) {
+      if (currentPage != null && !await currentPage.isUpdatable(sanitizeRevisionId, origin)) {
         const latestRevision = await Revision.findById(currentPage.revision).populate('author');
         const latestRevision = await Revision.findById(currentPage.revision).populate('author');
         const returnLatestRevision = {
         const returnLatestRevision = {
           revisionId: latestRevision?._id.toString(),
           revisionId: latestRevision?._id.toString(),
@@ -159,6 +161,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       }
       }
 
 
       let updatedPage: PageDocument;
       let updatedPage: PageDocument;
+      let previousRevision: IRevisionHasId | null;
       try {
       try {
         const {
         const {
           grant, userRelatedGrantUserGroupIds, overwriteScopesOfDescendants, wip,
           grant, userRelatedGrantUserGroupIds, overwriteScopesOfDescendants, wip,
@@ -168,7 +171,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
           options.grant = grant;
           options.grant = grant;
           options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
           options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
         }
         }
-        const previousRevision = await Revision.findById(revisionId);
+        previousRevision = await Revision.findById(sanitizeRevisionId);
         updatedPage = await crowi.pageService.updatePage(currentPage, body, previousRevision?.body ?? null, req.user, options);
         updatedPage = await crowi.pageService.updatePage(currentPage, body, previousRevision?.body ?? null, req.user, options);
       }
       }
       catch (err) {
       catch (err) {
@@ -183,7 +186,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
 
 
       res.apiv3(result, 201);
       res.apiv3(result, 201);
 
 
-      postAction(req, res, updatedPage);
+      postAction(req, res, updatedPage, previousRevision);
     },
     },
   ];
   ];
 };
 };

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

@@ -1,7 +1,7 @@
 import type { IPage } from '@growi/core';
 import type { IPage } from '@growi/core';
 import {
 import {
   type IGrantedGroup,
   type IGrantedGroup,
-  PageGrant, GroupType, getIdForRef, isPopulated,
+  PageGrant, GroupType, getIdForRef,
 } from '@growi/core';
 } from '@growi/core';
 import {
 import {
   pagePathUtils, pathUtils, pageUtils,
   pagePathUtils, pathUtils, pageUtils,
@@ -105,6 +105,7 @@ export interface IPageGrantService {
   getPopulatedGrantedGroups: (grantedGroups: IGrantedGroup[]) => Promise<PopulatedGrantedGroup[]>,
   getPopulatedGrantedGroups: (grantedGroups: IGrantedGroup[]) => Promise<PopulatedGrantedGroup[]>,
   getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
   getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
+  getNonUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
   isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
   getPageGroupGrantData: (page: PageDocument, user) => Promise<GroupGrantData>,
   getPageGroupGrantData: (page: PageDocument, user) => Promise<GroupGrantData>,
   calcApplicableGrantData: (page, user) => Promise<IRecordApplicableGrant>
   calcApplicableGrantData: (page, user) => Promise<IRecordApplicableGrant>
@@ -770,10 +771,18 @@ class PageGrantService implements IPageGrantService {
   getUserRelatedGrantedGroupsSyncronously(userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument): IGrantedGroup[] {
   getUserRelatedGrantedGroupsSyncronously(userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument): IGrantedGroup[] {
     const userRelatedGroupIds: string[] = userRelatedGroups.map(ug => ug.item._id.toString());
     const userRelatedGroupIds: string[] = userRelatedGroups.map(ug => ug.item._id.toString());
     return page.grantedGroups?.filter((group) => {
     return page.grantedGroups?.filter((group) => {
-      if (isPopulated(group.item)) {
-        return userRelatedGroupIds.includes(group.item._id.toString());
-      }
-      return userRelatedGroupIds.includes(group.item);
+      return userRelatedGroupIds.includes(getIdForRef(group.item).toString());
+    }) || [];
+  }
+
+  /*
+   * get all groups of Page that user is not related to
+   */
+  async getNonUserRelatedGrantedGroups(page: PageDocument, user): Promise<IGrantedGroup[]> {
+    const userRelatedGroups = (await this.getUserRelatedGroups(user));
+    const userRelatedGroupIds: string[] = userRelatedGroups.map(ug => ug.item._id.toString());
+    return page.grantedGroups?.filter((group) => {
+      return !userRelatedGroupIds.includes(getIdForRef(group.item).toString());
     }) || [];
     }) || [];
   }
   }
 
 

+ 12 - 7
apps/app/src/server/service/page/index.ts

@@ -3786,12 +3786,14 @@ class PageService implements IPageService {
     // Determine grantData
     // Determine grantData
     const grant = options.grant ?? closestAncestor?.grant ?? PageGrant.GRANT_PUBLIC;
     const grant = options.grant ?? closestAncestor?.grant ?? PageGrant.GRANT_PUBLIC;
     const grantUserIds = grant === PageGrant.GRANT_OWNER ? [user._id] : undefined;
     const grantUserIds = grant === PageGrant.GRANT_OWNER ? [user._id] : undefined;
-    const grantUserGroupIds = options.grantUserGroupIds
-      ?? (
-        closestAncestor != null
-          ? await this.pageGrantService.getUserRelatedGrantedGroups(closestAncestor, user)
-          : undefined
-      );
+    const getGrantedGroupsFromClosestAncestor = async() => {
+      if (closestAncestor == null) return undefined;
+      if (options.onlyInheritUserRelatedGrantedGroups) {
+        return this.pageGrantService.getUserRelatedGrantedGroups(closestAncestor, user);
+      }
+      return closestAncestor.grantedGroups;
+    };
+    const grantUserGroupIds = options.grantUserGroupIds ?? await getGrantedGroupsFromClosestAncestor();
     const grantData = {
     const grantData = {
       grant,
       grant,
       grantUserIds,
       grantUserIds,
@@ -4451,8 +4453,11 @@ class PageService implements IPageService {
 
 
   async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
   async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
     const yjsConnectionManager = getYjsConnectionManager();
     const yjsConnectionManager = getYjsConnectionManager();
+
     const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
     const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
-    const yjsDraft = currentYdoc?.getText('codemirror').toString();
+    const persistedYdoc = await yjsConnectionManager.getPersistedYdoc(pageId);
+
+    const yjsDraft = (currentYdoc ?? persistedYdoc)?.getText('codemirror').toString();
     const hasRevisionBodyDiff = await this.hasRevisionBodyDiff(pageId, yjsDraft);
     const hasRevisionBodyDiff = await this.hasRevisionBodyDiff(pageId, yjsDraft);
 
 
     return {
     return {

+ 4 - 2
apps/app/src/server/service/user-notification/index.ts

@@ -1,3 +1,5 @@
+import type { IRevisionHasId } from '@growi/core';
+
 import { toArrayFromCsv } from '~/utils/to-array-from-csv';
 import { toArrayFromCsv } from '~/utils/to-array-from-csv';
 
 
 
 
@@ -27,11 +29,11 @@ export class UserNotificationService {
    * @param {User} user
    * @param {User} user
    * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
    * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
    * @param {string} mode 'create' or 'update' or 'comment'
    * @param {string} mode 'create' or 'update' or 'comment'
-   * @param {string} previousRevision
+   * @param {IRevisionHasId} previousRevision
    * @param {Comment} comment
    * @param {Comment} comment
    */
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  async fire(page, user, slackChannelsStr, mode, option?: { previousRevision: string }, comment = {}): Promise<PromiseSettledResult<any>[]> {
+  async fire(page, user, slackChannelsStr, mode, option?: { previousRevision: IRevisionHasId }, comment = {}): Promise<PromiseSettledResult<any>[]> {
     const {
     const {
       appService, slackIntegrationService,
       appService, slackIntegrationService,
     } = this.crowi;
     } = this.crowi;

+ 7 - 2
apps/app/src/server/service/yjs-connection-manager.ts

@@ -54,13 +54,13 @@ class YjsConnectionManager {
       return;
       return;
     }
     }
 
 
-    const persistedYdoc = await this.mdb.getYDoc(pageId);
+    const persistedYdoc = await this.getPersistedYdoc(pageId);
     const persistedStateVector = Y.encodeStateVector(persistedYdoc);
     const persistedStateVector = Y.encodeStateVector(persistedYdoc);
 
 
     await this.mdb.flushDocument(pageId);
     await this.mdb.flushDocument(pageId);
 
 
     // If no write operation has been performed, insert initial value
     // If no write operation has been performed, insert initial value
-    const clientsSize = currentYdoc.store.clients.size;
+    const clientsSize = persistedYdoc.store.clients.size;
     if (clientsSize === 0) {
     if (clientsSize === 0) {
       currentYdoc.getText('codemirror').insert(0, initialValue);
       currentYdoc.getText('codemirror').insert(0, initialValue);
     }
     }
@@ -103,6 +103,11 @@ class YjsConnectionManager {
     return currentYdoc;
     return currentYdoc;
   }
   }
 
 
+  public async getPersistedYdoc(pageId: string): Promise<Y.Doc> {
+    const persistedYdoc = await this.mdb.getYDoc(pageId);
+    return persistedYdoc;
+  }
+
 }
 }
 
 
 export const instantiateYjsConnectionManager = (io: Server): YjsConnectionManager => {
 export const instantiateYjsConnectionManager = (io: Server): YjsConnectionManager => {

+ 10 - 0
apps/app/src/server/util/slack.js

@@ -24,7 +24,17 @@ const prepareAttachmentTextForCreate = function(page, siteUrl) {
   return convertMarkdownToMarkdown(body, siteUrl);
   return convertMarkdownToMarkdown(body, siteUrl);
 };
 };
 
 
+/**
+ * Return diff with latest revisionBody
+ * @param {IPageHasId} page
+ * @param {string} siteUrl
+ * @param {IRevisionHasId} previousRevision
+ */
 const prepareAttachmentTextForUpdate = function(page, siteUrl, previousRevision) {
 const prepareAttachmentTextForUpdate = function(page, siteUrl, previousRevision) {
+  if (previousRevision == null) {
+    return;
+  }
+
   const diff = require('diff');
   const diff = require('diff');
   let diffText = '';
   let diffText = '';
 
 

+ 33 - 2
apps/app/src/stores/modal.tsx

@@ -4,10 +4,9 @@ import type {
   IAttachmentHasId, IPageToDeleteWithMeta, IPageToRenameWithMeta, IUserGroupHasId,
   IAttachmentHasId, IPageToDeleteWithMeta, IPageToRenameWithMeta, IUserGroupHasId,
 } from '@growi/core';
 } from '@growi/core';
 import { useSWRStatic } from '@growi/core/dist/swr';
 import { useSWRStatic } from '@growi/core/dist/swr';
+import { MarkdownTable } from '@growi/editor';
 import type { SWRResponse } from 'swr';
 import type { SWRResponse } from 'swr';
 
 
-
-import MarkdownTable from '~/client/models/MarkdownTable';
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import type {
 import type {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction, onDeletedBookmarkFolderFunction, OnSelectedFunction,
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction, onDeletedBookmarkFolderFunction, OnSelectedFunction,
@@ -42,6 +41,38 @@ export const usePageCreateModal = (status?: CreateModalStatus): SWRResponse<Crea
   };
   };
 };
 };
 
 
+/*
+* GrantedGroupsInheritanceSelectModal
+*/
+type GrantedGroupsInheritanceSelectModalStatus = {
+  isOpened: boolean,
+  onCreateBtnClick?: (onlyInheritUserRelatedGrantedGroups?: boolean) => Promise<void>,
+}
+
+type GrantedGroupsInheritanceSelectModalStatusUtils = {
+  open(onCreateBtnClick?: (onlyInheritUserRelatedGrantedGroups?: boolean) => Promise<void>): Promise<GrantedGroupsInheritanceSelectModalStatus | undefined>
+  close(): Promise<GrantedGroupsInheritanceSelectModalStatus | undefined>
+}
+
+export const useGrantedGroupsInheritanceSelectModal = (
+    status?: GrantedGroupsInheritanceSelectModalStatus,
+): SWRResponse<GrantedGroupsInheritanceSelectModalStatus, Error> & GrantedGroupsInheritanceSelectModalStatusUtils => {
+  const initialData: GrantedGroupsInheritanceSelectModalStatus = { isOpened: false };
+  const swrResponse = useSWRStatic<GrantedGroupsInheritanceSelectModalStatus, Error>(
+    'grantedGroupsInheritanceSelectModalStatus', status, { fallbackData: initialData },
+  );
+
+  const { mutate } = swrResponse;
+
+  return {
+    ...swrResponse,
+    open: useCallback(
+      (onCreateBtnClick?: (onlyInheritUserRelatedGrantedGroups?: boolean) => Promise<void>) => mutate({ isOpened: true, onCreateBtnClick }), [mutate],
+    ),
+    close: useCallback(() => mutate({ isOpened: false }), [mutate]),
+  };
+};
+
 /*
 /*
 * PageDeleteModal
 * PageDeleteModal
 */
 */

+ 2 - 2
apps/app/src/stores/page.tsx

@@ -18,7 +18,7 @@ import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
 
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import type { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
+import type { IRecordApplicableGrant, IResCurrentGrantData } from '~/interfaces/page-grant';
 import type { AxiosResponse } from '~/utils/axios';
 import type { AxiosResponse } from '~/utils/axios';
 
 
 import type { IPageTagsInfo } from '../interfaces/tag';
 import type { IPageTagsInfo } from '../interfaces/tag';
@@ -270,7 +270,7 @@ export const useSWRxInfinitePageRevisions = (
  */
  */
 export const useSWRxCurrentGrantData = (
 export const useSWRxCurrentGrantData = (
     pageId: string | null | undefined,
     pageId: string | null | undefined,
-): SWRResponse<IResIsGrantNormalized, Error> => {
+): SWRResponse<IResCurrentGrantData, Error> => {
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();

+ 21 - 28
apps/app/src/styles/_editor.scss

@@ -43,34 +43,27 @@
       border-right: 1px solid transparent;
       border-right: 1px solid transparent;
 
 
       // add icon on cursor
       // add icon on cursor
-      .markdown-table-activated,
-      .markdown-link-activated {
-        .CodeMirror-cursor {
-          &::after {
-            position: relative;
-            top: -1.1em;
-            left: 0.3em;
-            display: block;
-            width: 1em;
-            height: 1em;
-            content: ' ';
-            background-repeat: no-repeat;
-            background-size: 1em;
-          }
-        }
-      }
-
-      .markdown-table-activated .CodeMirror-cursor {
-        &::after {
-          background-image: url('/images/icons/editor/table.svg');
-        }
-      }
-
-      .markdown-link-activated .CodeMirror-cursor {
-        &::after {
-          background-image: url('/images/icons/editor/link.svg');
-        }
-      }
+      // .markdown-link-activated {
+      //   .CodeMirror-cursor {
+      //     &::after {
+      //       position: relative;
+      //       top: -1.1em;
+      //       left: 0.3em;
+      //       display: block;
+      //       width: 1em;
+      //       height: 1em;
+      //       content: ' ';
+      //       background-repeat: no-repeat;
+      //       background-size: 1em;
+      //     }
+      //   }
+      // }
+
+      // .markdown-link-activated .CodeMirror-cursor {
+      //   &::after {
+      //     background-image: url('/images/icons/editor/link.svg');
+      //   }
+      // }
 
 
     }
     }
 
 

+ 57 - 0
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -358,6 +358,19 @@ describe('PageService page operations with non-public pages', () => {
         parent: rootPage._id,
         parent: rootPage._id,
         descendantCount: 0,
         descendantCount: 0,
       },
       },
+      {
+        path: '/mc6_top',
+        grant: Page.GRANT_USER_GROUP,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+        grantedGroups: [
+          { item: groupIdIsolate, type: GroupType.userGroup },
+          { item: groupIdB, type: GroupType.userGroup },
+        ],
+      },
     ]);
     ]);
 
 
     /**
     /**
@@ -958,6 +971,50 @@ describe('PageService page operations with non-public pages', () => {
         expect(isGrantNormalizedSpy).toBeCalledTimes(1);
         expect(isGrantNormalizedSpy).toBeCalledTimes(1);
       });
       });
     });
     });
+    describe('Creating a page under a page with grant USER_GROUP', () => {
+      describe('When onlyInheritUserRelatedGrantedGroups is true', () => {
+        test('Only user related groups should be inherited', async() => {
+          const pathT = '/mc6_top';
+          const pageT = await Page.findOne({ path: pathT });
+          expect(pageT).toBeTruthy();
+
+          const pathN = '/mc6_top/onlyRelatedGroupsInherited'; // path to create
+          await create(pathN, 'new body', npDummyUser1, { grant: Page.GRANT_USER_GROUP, onlyInheritUserRelatedGrantedGroups: true });
+
+          const _pageT = await Page.findOne({ path: pathT });
+          const _pageN = await Page.findOne({ path: pathN, grant: Page.GRANT_USER_GROUP }); // newly crated
+          expect(_pageT).toBeTruthy();
+          expect(_pageN).toBeTruthy();
+          expect(_pageN.parent).toStrictEqual(_pageT._id);
+          expect(_pageT.descendantCount).toStrictEqual(1);
+          expect(normalizeGrantedGroups(_pageN.grantedGroups)).toStrictEqual([
+            { item: groupIdIsolate, type: GroupType.userGroup },
+          ]);
+        });
+      });
+
+      describe('When onlyInheritUserRelatedGrantedGroups is false', () => {
+        test('All groups should be inherited', async() => {
+          const pathT = '/mc6_top';
+          const pageT = await Page.findOne({ path: pathT });
+          expect(pageT).toBeTruthy();
+
+          const pathN = '/mc6_top/allGroupsInherited'; // path to create
+          await create(pathN, 'new body', npDummyUser1, { grant: Page.GRANT_USER_GROUP, onlyInheritUserRelatedGrantedGroups: false });
+
+          const _pageT = await Page.findOne({ path: pathT });
+          const _pageN = await Page.findOne({ path: pathN, grant: Page.GRANT_USER_GROUP }); // newly crated
+          expect(_pageT).toBeTruthy();
+          expect(_pageN).toBeTruthy();
+          expect(_pageN.parent).toStrictEqual(_pageT._id);
+          expect(_pageT.descendantCount).toStrictEqual(2);
+          expect(normalizeGrantedGroups(_pageN.grantedGroups)).toStrictEqual([
+            { item: groupIdIsolate, type: GroupType.userGroup },
+            { item: groupIdB, type: GroupType.userGroup },
+          ]);
+        });
+      });
+    });
 
 
   });
   });
 
 

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

@@ -1,7 +1,8 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "7.0.8-slackbot-proxy.0",
+  "version": "7.0.9-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
+  "private": "true",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "clean": "shx rm -rf dist",
     "clean": "shx rm -rf dist",

+ 4 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "7.0.8-RC.0",
+  "version": "7.0.9-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
@@ -37,6 +37,9 @@
     "app:server": "cd apps/app && yarn server",
     "app:server": "cd apps/app && yarn server",
     "slackbot-proxy:build": "turbo run build --filter @growi/slackbot-proxy",
     "slackbot-proxy:build": "turbo run build --filter @growi/slackbot-proxy",
     "slackbot-proxy:server": "cd apps/slackbot-proxy && yarn start:prod",
     "slackbot-proxy:server": "cd apps/slackbot-proxy && yarn start:prod",
+    "version-subpackages": "changeset version && yarn upgrade --scope=@growi",
+    "release-subpackages": "turbo run build --filter @growi/core --filter @growi/pluginkit && changeset publish",
+    "release-subpackages:snapshot": "turbo run build --filter @growi/core --filter @growi/pluginkit && changeset version --snapshot next && changeset publish --no-git-tag --snapshot --tag next",
     "version": "yarn version --no-git-tag-version --preid=RC"
     "version": "yarn version --no-git-tag-version --preid=RC"
   },
   },
   "dependencies": {
   "dependencies": {

+ 7 - 0
packages/core-styles/CHANGELOG.md

@@ -0,0 +1,7 @@
+# @growi/core-styles
+
+## 1.0.0
+
+### Major Changes
+
+- [#8844](https://github.com/weseek/growi/pull/8844) [`37d88a8`](https://github.com/weseek/growi/commit/37d88a858c3e54d741790760fbfad4fd7a229949) Thanks [@github-actions](https://github.com/apps/github-actions)! - The first major version release

+ 1 - 1
packages/core-styles/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/core-styles",
   "name": "@growi/core-styles",
-  "version": "0.9.0",
+  "version": "1.0.0",
   "description": "GROWI Core Style Files",
   "description": "GROWI Core Style Files",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 11 - 0
packages/core/CHANGELOG.md

@@ -0,0 +1,11 @@
+# @growi/core
+
+## 1.0.0
+
+### Major Changes
+
+- [#8844](https://github.com/weseek/growi/pull/8844) [`37d88a8`](https://github.com/weseek/growi/commit/37d88a858c3e54d741790760fbfad4fd7a229949) Thanks [@github-actions](https://github.com/apps/github-actions)! - The first major version release
+
+### Minor Changes
+
+- [#8856](https://github.com/weseek/growi/pull/8856) [`47ce932`](https://github.com/weseek/growi/commit/47ce932a066b8bdd16f600f2526d6f0d10b7b763) Thanks [@yuki-takei](https://github.com/yuki-takei)! - Add vaidator for GROWI theme plugins

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/core",
   "name": "@growi/core",
-  "version": "0.9.0",
+  "version": "1.0.0",
   "description": "GROWI Core Libraries",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 18 - 0
packages/core/src/interfaces/growi-theme-metadata.ts

@@ -19,3 +19,21 @@ export type GrowiThemeMetadata = {
   createBtn: string,
   createBtn: string,
   isPresetTheme?: boolean,
   isPresetTheme?: boolean,
 };
 };
+
+export const isGrowiThemeMetadata = (obj: unknown): obj is GrowiThemeMetadata => {
+  const objAny = obj as any;
+
+  return objAny != null
+    && typeof objAny === 'object'
+    && Array.isArray(objAny) === false
+    && 'name' in objAny && typeof objAny.name === 'string'
+    && 'manifestKey' in objAny && typeof objAny.manifestKey === 'string'
+    && 'schemeType' in objAny && typeof objAny.schemeType === 'string'
+    && 'lightBg' in objAny && typeof objAny.lightBg === 'string'
+    && 'darkBg' in objAny && typeof objAny.darkBg === 'string'
+    && 'lightSidebar' in objAny && typeof objAny.lightSidebar === 'string'
+    && 'darkSidebar' in objAny && typeof objAny.darkSidebar === 'string'
+    && 'lightIcon' in objAny && typeof objAny.lightIcon === 'string'
+    && 'darkIcon' in objAny && typeof objAny.darkIcon === 'string'
+    && 'createBtn' in objAny && typeof objAny.createBtn === 'string';
+};

+ 5 - 1
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.module.scss → packages/editor/src/client/components/CodeMirrorEditor/CodeMirrorEditor.module.scss

@@ -154,7 +154,11 @@
 
 
   .markdown-table-activated .cm-cursor.cm-cursor-primary {
   .markdown-table-activated .cm-cursor.cm-cursor-primary {
     &:after {
     &:after {
-      background-image: url(../../../svg/table.svg);
+      font-family: var(--grw-font-family-material-symbols-outlined);
+      font-size: 1.5em;
+      content: 'table_chart';
+      opacity: 0.7;
+      transform: translateY(-1.2em)
     }
     }
   }
   }
 }
 }

+ 4 - 7
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx → packages/editor/src/client/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -1,6 +1,6 @@
+import type { DetailedHTMLProps } from 'react';
 import {
 import {
   forwardRef, useMemo, useRef, useEffect,
   forwardRef, useMemo, useRef, useEffect,
-  DetailedHTMLProps,
 } from 'react';
 } from 'react';
 
 
 import { indentUnit } from '@codemirror/language';
 import { indentUnit } from '@codemirror/language';
@@ -10,14 +10,11 @@ import {
 import { AcceptedUploadFileType } from '@growi/core';
 import { AcceptedUploadFileType } from '@growi/core';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
 
-import { EditorSettings, GlobalCodeMirrorEditorKey } from '../../consts';
+import type { EditorSettings, GlobalCodeMirrorEditorKey } from '../../../consts';
 import {
 import {
   useFileDropzone, FileDropzoneOverlay,
   useFileDropzone, FileDropzoneOverlay,
-} from '../../services';
-import {
-  adjustPasteData, getStrFromBol,
-} from '../../services/paste-util/paste-markdown-util';
-import { useShowTableIcon } from '../../services/table-util/use-show-table-icon';
+  adjustPasteData, getStrFromBol, useShowTableIcon,
+} from '../../services-internal';
 import { useDefaultExtensions, useCodeMirrorEditorIsolated, useEditorSettings } from '../../stores';
 import { useDefaultExtensions, useCodeMirrorEditorIsolated, useEditorSettings } from '../../stores';
 
 
 import { Toolbar } from './Toolbar';
 import { Toolbar } from './Toolbar';

+ 3 - 3
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropdownItem.tsx → packages/editor/src/client/components/CodeMirrorEditor/Toolbar/AttachmentsDropdownItem.tsx

@@ -1,11 +1,11 @@
-import { ReactNode } from 'react';
+import type { ReactNode } from 'react';
 
 
-import { AcceptedUploadFileType } from '@growi/core';
+import type { AcceptedUploadFileType } from '@growi/core';
 import {
 import {
   DropdownItem,
   DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { useFileDropzone } from '../../../services';
+import { useFileDropzone } from '../../../services-internal';
 
 
 type Props = {
 type Props = {
   acceptedUploadFileType: AcceptedUploadFileType,
   acceptedUploadFileType: AcceptedUploadFileType,

+ 0 - 0
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.module.scss → packages/editor/src/client/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.module.scss


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

@@ -8,7 +8,7 @@ import {
   Dropdown,
   Dropdown,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import type { GlobalCodeMirrorEditorKey } from '../../../consts';
+import type { GlobalCodeMirrorEditorKey } from '../../../../consts';
 
 
 import { AttachmentsDropdownItem } from './AttachmentsDropdownItem';
 import { AttachmentsDropdownItem } from './AttachmentsDropdownItem';
 import { LinkEditButton } from './LinkEditButton';
 import { LinkEditButton } from './LinkEditButton';

+ 0 - 0
packages/editor/src/components/CodeMirrorEditor/Toolbar/DiagramButton.tsx → packages/editor/src/client/components/CodeMirrorEditor/Toolbar/DiagramButton.tsx


+ 0 - 0
packages/editor/src/components/CodeMirrorEditor/Toolbar/EmojiButton.tsx → packages/editor/src/client/components/CodeMirrorEditor/Toolbar/EmojiButton.tsx


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

@@ -2,8 +2,8 @@ import { useCallback } from 'react';
 
 
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
-import type { GlobalCodeMirrorEditorKey } from '../../../consts';
-import { getMarkdownLink, replaceFocusedMarkdownLinkWithEditor } from '../../../services/link-util/markdown-link-util';
+import type { GlobalCodeMirrorEditorKey } from '../../../../consts';
+import { getMarkdownLink, replaceFocusedMarkdownLinkWithEditor } from '../../../services-internal';
 import { useCodeMirrorEditorIsolated } from '../../../stores';
 import { useCodeMirrorEditorIsolated } from '../../../stores';
 import { useLinkEditModal } from '../../../stores/use-link-edit-modal';
 import { useLinkEditModal } from '../../../stores/use-link-edit-modal';
 
 

+ 0 - 0
packages/editor/src/components/CodeMirrorEditor/Toolbar/TableButton.tsx → packages/editor/src/client/components/CodeMirrorEditor/Toolbar/TableButton.tsx


+ 0 - 0
packages/editor/src/components/CodeMirrorEditor/Toolbar/TemplateButton.tsx → packages/editor/src/client/components/CodeMirrorEditor/Toolbar/TemplateButton.tsx


+ 0 - 0
packages/editor/src/components/CodeMirrorEditor/Toolbar/TextFormatTools.module.scss → packages/editor/src/client/components/CodeMirrorEditor/Toolbar/TextFormatTools.module.scss


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

@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
 
 
 import { Collapse } from 'reactstrap';
 import { Collapse } from 'reactstrap';
 
 
-import type { GlobalCodeMirrorEditorKey } from '../../../consts';
+import type { GlobalCodeMirrorEditorKey } from '../../../../consts';
 import { useCodeMirrorEditorIsolated } from '../../../stores';
 import { useCodeMirrorEditorIsolated } from '../../../stores';
 
 
 import styles from './TextFormatTools.module.scss';
 import styles from './TextFormatTools.module.scss';

Некоторые файлы не были показаны из-за большого количества измененных файлов