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

Merge branch 'support/apply-nextjs-2' of https://github.com/weseek/growi into feat/104904-render-forgot-password-page

Shun Miyazawa 3 лет назад
Родитель
Сommit
5298456666
67 измененных файлов с 1326 добавлено и 1034 удалено
  1. 0 1
      packages/app/config/ci/.env.local.for-ci
  2. 9 2
      packages/app/docker/Dockerfile
  3. 3 0
      packages/app/next.config.js
  4. 4 1
      packages/app/package.json
  5. 11 1
      packages/app/public/static/locales/en_US/translation.json
  6. 11 1
      packages/app/public/static/locales/ja_JP/translation.json
  7. 11 1
      packages/app/public/static/locales/zh_CN/translation.json
  8. 8 14
      packages/app/resource/locales/en_US/sandbox-diagrams.md
  9. 1 1
      packages/app/resource/locales/en_US/sandbox-math.md
  10. 34 34
      packages/app/resource/locales/en_US/sandbox.md
  11. 8 15
      packages/app/resource/locales/ja_JP/sandbox-diagrams.md
  12. 1 1
      packages/app/resource/locales/ja_JP/sandbox-math.md
  13. 34 34
      packages/app/resource/locales/ja_JP/sandbox.md
  14. 8 14
      packages/app/resource/locales/zh_CN/sandbox-diagrams.md
  15. 1 1
      packages/app/resource/locales/zh_CN/sandbox-math.md
  16. 34 34
      packages/app/resource/locales/zh_CN/sandbox.md
  17. 0 28
      packages/app/src/client/util/blink-section-header.ts
  18. 2 0
      packages/app/src/client/util/editor.ts
  19. 17 10
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  20. 174 144
      packages/app/src/components/LoginForm.tsx
  21. 17 25
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  22. 15 18
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  23. 1 12
      packages/app/src/components/Page.tsx
  24. 6 6
      packages/app/src/components/Page/DisplaySwitcher.tsx
  25. 0 1
      packages/app/src/components/Page/RevisionLoader.jsx
  26. 1 75
      packages/app/src/components/Page/RevisionRenderer.tsx
  27. 62 65
      packages/app/src/components/PageAttachment.tsx
  28. 0 97
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx
  29. 98 0
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  30. 0 45
      packages/app/src/components/PageAttachment/PageAttachmentList.jsx
  31. 47 0
      packages/app/src/components/PageAttachment/PageAttachmentList.tsx
  32. 13 27
      packages/app/src/components/PageComment.tsx
  33. 5 3
      packages/app/src/components/PageComment/Comment.tsx
  34. 7 1
      packages/app/src/components/PageComment/CommentEditor.tsx
  35. 8 1
      packages/app/src/components/PageComment/ReplyComments.tsx
  36. 5 0
      packages/app/src/components/PageEditor/CodeMirrorEditor.module.scss
  37. 117 98
      packages/app/src/components/PageEditorByHackmd.tsx
  38. 22 1
      packages/app/src/components/ReactMarkdownComponents/Header.tsx
  39. 14 2
      packages/app/src/components/ReactMarkdownComponents/NextLink.tsx
  40. 2 5
      packages/app/src/components/SavePageControls.tsx
  41. 2 1
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  42. 4 1
      packages/app/src/components/TableOfContents.module.scss
  43. 2 11
      packages/app/src/components/TableOfContents.tsx
  44. 1 1
      packages/app/src/components/Theme/ThemeDefault.global.scss
  45. 2 4
      packages/app/src/interfaces/attachment.ts
  46. 1 0
      packages/app/src/interfaces/editor-settings.ts
  47. 26 6
      packages/app/src/pages/login.page.tsx
  48. 7 0
      packages/app/src/server/crowi/express-init.js
  49. 2 2
      packages/app/src/server/middlewares/register-form-validator.ts
  50. 9 1
      packages/app/src/server/routes/apiv3/index.js
  51. 2 3
      packages/app/src/server/routes/index.js
  52. 73 85
      packages/app/src/server/routes/login.js
  53. 0 1
      packages/app/src/server/service/app.ts
  54. 1 1
      packages/app/src/server/service/installer.ts
  55. 90 0
      packages/app/src/services/renderer/rehype-plugins/keyword-highlighter.ts
  56. 54 0
      packages/app/src/services/renderer/rehype-plugins/relocate-toc.ts
  57. 12 0
      packages/app/src/services/renderer/remark-plugins/plantuml.ts
  58. 48 0
      packages/app/src/services/renderer/remark-plugins/xsv-to-table.ts
  59. 42 63
      packages/app/src/services/renderer/renderer.tsx
  60. 13 9
      packages/app/src/stores/attachment.tsx
  61. 37 10
      packages/app/src/stores/renderer.tsx
  62. 6 7
      packages/app/src/stores/ui.tsx
  63. 9 0
      packages/app/src/styles/theme/_apply-colors-dark.scss
  64. 9 0
      packages/app/src/styles/theme/_apply-colors-light.scss
  65. 2 1
      packages/plugin-lsx/package.json
  66. 3 1
      packages/ui/src/components/Attachment/Attachment.jsx
  67. 58 7
      yarn.lock

+ 0 - 1
packages/app/config/ci/.env.local.for-ci

@@ -1,2 +1 @@
 FORMAT_NODE_LOG=true
-MATHJAX=1

+ 9 - 2
packages/app/docker/Dockerfile

@@ -26,6 +26,10 @@ ENV optDir /opt
 
 WORKDIR ${optDir}
 
+ENV nodeModulesGrowiPackagesDir ${optDir}/node_modules/@growi
+# expect a string seperated by commas (e.g. "A,B")
+ENV removeNodeModulesSymlinkPaths ${nodeModulesGrowiPackagesDir}/slackbot-proxy
+
 # copy files
 COPY --from=packages-json-picker ${optDir} .
 
@@ -33,6 +37,9 @@ COPY --from=packages-json-picker ${optDir} .
 RUN yarn config set network-timeout 300000
 RUN npx -y lerna bootstrap -- --frozen-lockfile
 
+# remove unnecessary symlinks
+RUN rm -f $(echo ${removeNodeModulesSymlinkPaths} | sed -e "s/,/ /g")
+
 # make artifacts
 RUN tar -cf node_modules.tar \
   node_modules \
@@ -55,7 +62,6 @@ RUN tar -cf node_modules.tar \
   packages/*/node_modules
 
 
-
 ##
 ## prebuilder-default
 ##
@@ -74,7 +80,6 @@ RUN tar -xf node_modules.tar
 RUN rm node_modules.tar
 
 
-
 ##
 ## prebuilder-nocdn
 ##
@@ -94,6 +99,7 @@ ENV optDir /opt
 
 WORKDIR ${optDir}
 
+# ignore eslint and stylelint
 COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 # copy all related packages
 COPY packages/app packages/app
@@ -103,6 +109,7 @@ COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
 COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/slack packages/slack
 COPY packages/ui packages/ui
+COPY packages/remark-growi-plugin packages/remark-growi-plugin
 
 # build
 RUN yarn lerna run build

+ 3 - 0
packages/app/next.config.js

@@ -63,6 +63,9 @@ module.exports = async(phase, { defaultConfig }) => {
     // see: https://github.com/vercel/next.js/discussions/27876
     // experimental: { esmExternals: true }, // Prefer loading of ES Modules over CommonJS
 
+    eslint: {
+      ignoreDuringBuilds: true,
+    },
     reactStrictMode: true,
     swcMinify: true,
     typescript: {

+ 4 - 1
packages/app/package.json

@@ -56,6 +56,7 @@
     "string-width": "5.0.0 or above exports only ESM."
   },
   "dependencies": {
+    "@akebifiky/remark-simple-plantuml": "^1.0.2",
     "@aws-sdk/client-s3": "^3.58.0",
     "@aws-sdk/s3-request-presigner": "^3.58.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
@@ -91,6 +92,7 @@
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
     "csurf": "^1.11.0",
+    "csv-to-markdown-table": "^1.1.0",
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
@@ -209,7 +211,6 @@
     "colors": "=1.4.0",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
-    "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
@@ -236,9 +237,11 @@
     "react-dropzone": "^11.2.4",
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
+    "react-scroll": "^1.8.7",
     "react-use-ripple": "^1.5.2",
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
+    "rehype-rewrite": "^3.0.6",
     "replacestream": "^4.0.3",
     "reveal.js": "^4.3.1",
     "sass": "^1.53.0",

+ 11 - 1
packages/app/public/static/locales/en_US/translation.json

@@ -709,7 +709,17 @@
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
     "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}",
     "successfully_send_email_auth":"We sent an email to {{email}}. Please click the URL in the email and complete the registration.",
-    "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired."
+    "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired.",
+    "user_already_loggedin": "You cannot create a new account when you are logged in.",
+    "registration_closed": "You are not authorized to create a new account.",
+    "Username has invalid characters": "Username has invalid characters.",
+    "Username field is required": "User ID field is required",
+    "Name field is required": "Name field is required",
+    "Email format is invalid": "Email format is invalid",
+    "Email field is required": "Email field is required",
+    "Password has invalid character": "Password has invalid character",
+    "Password minimum character should be more than 8 characters": "Password minimum character should be more than 8 characters",
+    "Password field is required": "Password field is required"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",

+ 11 - 1
packages/app/public/static/locales/ja_JP/translation.json

@@ -700,7 +700,17 @@
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
     "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}",
     "successfully_send_email_auth":"{{email}} にメールを送信しました。添付されたURLをクリックし、本登録を完了させてください",
-    "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。"
+    "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。",
+    "user_already_loggedin": "ログイン中のため、新規アカウントを作成できませんでした。",
+    "registration_closed": "新しいアカウントを作成する権限がありません。",
+    "Username has invalid characters": "ユーザー名に不正な文字が含まれています.",
+    "Username field is required": "User ID は必須項目です",
+    "Name field is required": "ユーザーID は必須項目です",
+    "Email format is invalid": "メールアドレスのフォーマットが無効です",
+    "Email field is required": "メールアドレスは必須項目です",
+    "Password has invalid character": "パスワードに無効な文字があります",
+    "Password minimum character should be more than 8 characters": "パスワードの最小文字数は8文字以上です",
+    "Password field is required": "パスワードの欄は必ず入力してください"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",

+ 11 - 1
packages/app/public/static/locales/zh_CN/translation.json

@@ -756,7 +756,17 @@
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
 		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}",
     "successfully_send_email_auth":"我们向 {{email}} 发送了一封电子邮件。 请点击邮件中的网址并完成注册。",
-    "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。"
+    "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。",
+    "user_already_loggedin": "当你登录的时候,你不能创建一个新的账户。",
+    "registration_closed": "你无权创建一个新的账户。",
+    "Username has invalid characters": "用户名有无效字符",
+    "Username field is required": "用户ID字段是必需的",
+    "Name field is required": "姓名字段为必填项",
+    "Email format is invalid": "电子邮件的格式是无效的",
+    "Email field is required": "电子邮件字段是必需的",
+    "Password has invalid character": "密码有无效字符",
+    "Password minimum character should be more than 8 characters": "密码最小字符应超过8个字符",
+    "Password field is required": "密码字段是必需的"
 	},
   "grid_edit":{
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",

+ 8 - 14
packages/app/resource/locales/en_US/sandbox-diagrams.md

@@ -29,6 +29,7 @@ See [PlantUML](http://plantuml.com/).
 
 ## Sequence diagram
 
+``` plantuml
 @startuml
 skinparam sequenceArrowThickness 2
 skinparam roundcorner 20
@@ -58,13 +59,12 @@ A --> User: Done
 deactivate A
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## Class diagram
 
+``` plantuml
 @startuml
 
 class BaseClass
@@ -86,13 +86,11 @@ namespace net.foo {
 BaseClass <|-- net.unused.Person
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## Component diagram
-
+``` plantuml
 @startuml
 
 package "Some Group" {
@@ -125,14 +123,12 @@ database "MySql" {
 [Folder 3] --> [Frame 4]
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## State diagram
 
-
+``` plantuml
 @startuml
 scale 600 width
 
@@ -153,9 +149,7 @@ State3 --> [*] : Succeeded / Save Result
 State3 --> [*] : Aborted
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 # :pencil: blockdiag

+ 1 - 1
packages/app/resource/locales/en_US/sandbox-math.md

@@ -1,6 +1,6 @@
 # :pencil: Math
 
-See [MathJax](https://www.mathjax.org/).
+See [KaTeX](https://katex.org/).
 
 ## Inline Formula
 

+ 34 - 34
packages/app/resource/locales/en_US/sandbox.md

@@ -351,65 +351,65 @@ aligned    | aligned     | aligned
 | left       | right       | center       |
 | aligned    | aligned     | aligned      |
 
-## TSV (crowi-plus notation)
+## TSV
 
+~~~
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv
-Content Cell  Content Cell
-Content Cell  Content Cell
-:::
-```
+~~~
 
-::: tsv
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## TSV with header (crowi-plus notation)
+## TSV with header
 
+~~~
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
-```
+~~~
 
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## CSV (crowi-plus original notation)
+## CSV
 
-```
-::: csv
+~~~
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
-## CSV with header (crowi-plus original notation)
+## CSV with header
 
-```
-::: csv-h
+~~~
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv-h
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
 
 # :memo: Footnote

+ 8 - 15
packages/app/resource/locales/ja_JP/sandbox-diagrams.md

@@ -28,7 +28,7 @@ See [diagrams.net](https://diagrams.net)
 See [PlantUML](http://plantuml.com/).
 
 ## シーケンス図
-
+``` plantuml
 @startuml
 skinparam sequenceArrowThickness 2
 skinparam roundcorner 20
@@ -58,13 +58,11 @@ A --> User: Done
 deactivate A
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## クラス図
-
+``` plantuml
 @startuml
 
 class BaseClass
@@ -86,13 +84,11 @@ namespace net.foo {
 BaseClass <|-- net.unused.Person
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## コンポーネント図
-
+``` plantuml
 @startuml
 
 package "Some Group" {
@@ -125,14 +121,12 @@ database "MySql" {
 [Folder 3] --> [Frame 4]
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## ステート図
 
-
+``` plantuml
 @startuml
 scale 600 width
 
@@ -153,9 +147,8 @@ State3 --> [*] : Succeeded / Save Result
 State3 --> [*] : Aborted
 
 @enduml
+```
 
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
 
 # :pencil: blockdiag
 

+ 1 - 1
packages/app/resource/locales/ja_JP/sandbox-math.md

@@ -1,6 +1,6 @@
 # :pencil: Math
 
-See [MathJax](https://www.mathjax.org/).
+See [KaTeX](https://katex.org/).
 
 ## Inline Formula
 

+ 34 - 34
packages/app/resource/locales/ja_JP/sandbox.md

@@ -350,65 +350,65 @@ aligned    | aligned     | aligned
 | left       | right       | center       |
 | aligned    | aligned     | aligned      |
 
-## TSV (crowi-plus 独自記法)
+## TSV
 
+~~~
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv
-Content Cell  Content Cell
-Content Cell  Content Cell
-:::
-```
+~~~
 
-::: tsv
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## TSV ヘッダ付き (crowi-plus 独自記法)
+## TSV (ヘッダ付き)
 
+~~~
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
-```
+~~~
 
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## CSV (crowi-plus 独自記法)
+## CSV
 
-```
-::: csv
+~~~
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
-## CSV ヘッダ付き (crowi-plus 独自記法)
+## CSV (ヘッダ付き)
 
-```
-::: csv-h
+~~~
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv-h
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
 
 # :memo: Footnote

+ 8 - 14
packages/app/resource/locales/zh_CN/sandbox-diagrams.md

@@ -29,6 +29,7 @@ See [PlantUML](http://plantuml.com/).
 
 ## Sequence diagram
 
+``` plantuml
 @startuml
 skinparam sequenceArrowThickness 2
 skinparam roundcorner 20
@@ -58,13 +59,12 @@ A --> User: Done
 deactivate A
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## Class diagram
 
+``` plantuml
 @startuml
 
 class BaseClass
@@ -86,13 +86,11 @@ namespace net.foo {
 BaseClass <|-- net.unused.Person
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## Component diagram
-
+``` plantuml
 @startuml
 
 package "Some Group" {
@@ -125,14 +123,12 @@ database "MySql" {
 [Folder 3] --> [Frame 4]
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## State diagram
 
-
+``` plantuml
 @startuml
 scale 600 width
 
@@ -153,9 +149,7 @@ State3 --> [*] : Succeeded / Save Result
 State3 --> [*] : Aborted
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 # :pencil: blockdiag

+ 1 - 1
packages/app/resource/locales/zh_CN/sandbox-math.md

@@ -1,6 +1,6 @@
 # :pencil: Math
 
-See [MathJax](https://www.mathjax.org/).
+See [KaTeX](https://katex.org/).
 
 ## Inline Formula
 

+ 34 - 34
packages/app/resource/locales/zh_CN/sandbox.md

@@ -351,65 +351,65 @@ aligned    | aligned     | aligned
 | left       | right       | center       |
 | aligned    | aligned     | aligned      |
 
-## TSV (crowi-plus notation)
+## TSV
 
+~~~
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv
-Content Cell  Content Cell
-Content Cell  Content Cell
-:::
-```
+~~~
 
-::: tsv
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## TSV with header (crowi-plus notation)
+## TSV with header
 
+~~~
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
-```
+~~~
 
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## CSV (crowi-plus original notation)
+## CSV
 
-```
-::: csv
+~~~
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
-## CSV with header (crowi-plus original notation)
+## CSV with header
 
-```
-::: csv-h
+~~~
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv-h
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
 
 # :memo: Footnote

+ 0 - 28
packages/app/src/client/util/blink-section-header.ts

@@ -1,28 +0,0 @@
-let lastBlinkedElem;
-
-export const blinkElem = (elem: HTMLElement): void => {
-  if (lastBlinkedElem != null) {
-    lastBlinkedElem.classList.remove('blink');
-  }
-
-  elem.classList.add('blink');
-  lastBlinkedElem = elem;
-};
-
-export const blinkSectionHeaderAtBoot = (): HTMLElement | undefined => {
-  const { hash } = window.location;
-
-  if (hash.length === 0) {
-    return;
-  }
-
-  // omit '#'
-  const id = hash.replace('#', '');
-  // don't use jQuery and document.querySelector
-  //  because hash may containe Base64 encoded strings
-  const elem = document.getElementById(id);
-  if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
-    blinkElem(elem);
-    return elem;
-  }
-};

+ 2 - 0
packages/app/src/client/util/editor.ts

@@ -7,6 +7,7 @@ export const getOptionsToSave = (
     grantUserGroupId: string | null | undefined,
     grantUserGroupName: string | null | undefined,
     pageTags: string[],
+    isSyncRevisionToHackmd?: boolean,
 ): OptionsToSave => {
   return {
     pageTags,
@@ -15,5 +16,6 @@ export const getOptionsToSave = (
     grant,
     grantUserGroupId,
     grantUserGroupName,
+    isSyncRevisionToHackmd,
   };
 };

+ 17 - 10
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -1,11 +1,8 @@
-/* eslint-disable no-multi-spaces */
-/* eslint-disable react/jsx-props-no-multi-spaces */
-
-
 import React from 'react';
 
 import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import PropTypes from 'prop-types';
 import urljoin from 'url-join';
 
@@ -25,6 +22,7 @@ const AdminNavigation = (props) => {
   // eslint-disable-next-line react/prop-types
   const MenuLabel = ({ menu }) => {
     switch (menu) {
+      /* eslint-disable no-multi-spaces */
       case 'app':                      return <><i className="icon-fw icon-settings"></i>        { t('app_settings') }</>;
       case 'security':                 return <><i className="icon-fw icon-shield"></i>          { t('security_settings.security_settings') }</>;
       case 'markdown':                 return <><i className="icon-fw icon-note"></i>            { t('markdown_settings.markdown_settings') }</>;
@@ -42,6 +40,7 @@ const AdminNavigation = (props) => {
       case 'audit-log':                return <><i className="icon-fw icon-feed"></i>            { t('audit_log_management.audit_log')}</>;
       case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;
       default:                         return <><i className="icon-fw icon-home"></i>            { t('wiki_management_home_page') }</>;
+      /* eslint-enable no-multi-spaces */
     }
   };
 
@@ -53,13 +52,17 @@ const AdminNavigation = (props) => {
       ? 'list-group-item list-group-item-action border-0 round-corner'
       : 'dropdown-item px-3 py-2';
 
+    const href = isRoot ? '/admin' : urljoin('/admin', menu);
+
     return (
-      <a
-        href={isRoot ? '/admin' : urljoin('/admin', menu)}
-        className={`${pageTransitionClassName} ${isActive ? 'active' : ''}`}
-      >
-        <MenuLabel menu={menu} />
-      </a>
+      <Link href={href}>
+        <a
+          href={href}
+          className={`${pageTransitionClassName} ${isActive ? 'active' : ''}`}
+        >
+          <MenuLabel menu={menu} />
+        </a>
+      </Link>
     );
   };
 
@@ -76,6 +79,7 @@ const AdminNavigation = (props) => {
   const getListGroupItemOrDropdownItemList = (isListGroupItems) => {
     return (
       <>
+        {/* eslint-disable no-multi-spaces */}
         <MenuLink menu="home"         isListGroupItems isActive={pathname === '/admin'} isRoot />
         <MenuLink menu="app"          isListGroupItems isActive={isActiveMenu('/app')} />
         <MenuLink menu="security"     isListGroupItems isActive={isActiveMenu('/security')} />
@@ -100,6 +104,7 @@ const AdminNavigation = (props) => {
             </a>
           )
         } */}
+        {/* eslint-enable no-multi-spaces */}
       </>
     );
   };
@@ -123,6 +128,7 @@ const AdminNavigation = (props) => {
           aria-expanded="false"
         >
           <span className="float-left">
+            {/* eslint-disable no-multi-spaces */}
             {pathname === '/admin' &&              <MenuLabel menu="home" />}
             {isActiveMenu('/app') &&               <MenuLabel menu="app" />}
             {isActiveMenu('/security') &&          <MenuLabel menu="security" />}
@@ -136,6 +142,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
+            {/* eslint-enable no-multi-spaces */}
           </span>
         </button>
         <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">

+ 174 - 144
packages/app/src/components/LoginForm.jsx → packages/app/src/components/LoginForm.tsx

@@ -1,44 +1,64 @@
-import React from 'react';
+import React, {
+  useState, useEffect, useCallback,
+} from 'react';
 
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
+import { useRouter } from 'next/router';
 import ReactCardFlip from 'react-card-flip';
 
+import { apiv3Post } from '~/client/util/apiv3-client';
 import { useCsrfToken } from '~/stores/context';
 
-class LoginForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isRegistering: false,
-    };
-
-    this.switchForm = this.switchForm.bind(this);
-    this.handleLoginWithExternalAuth = this.handleLoginWithExternalAuth.bind(this);
-    this.renderLocalOrLdapLoginForm = this.renderLocalOrLdapLoginForm.bind(this);
-    this.renderExternalAuthLoginForm = this.renderExternalAuthLoginForm.bind(this);
-    this.renderExternalAuthInput = this.renderExternalAuthInput.bind(this);
-    this.renderRegisterForm = this.renderRegisterForm.bind(this);
+type LoginFormProps = {
+  username?: string,
+  name?: string,
+  email?: string,
+  isRegistrationEnabled: boolean,
+  isEmailAuthenticationEnabled: boolean,
+  registrationMode?: string,
+  registrationWhiteList: string[],
+  isPasswordResetEnabled: boolean,
+  isLocalStrategySetup: boolean,
+  isLdapStrategySetup: boolean,
+  objOfIsExternalAuthEnableds?: any,
+  isMailerSetup?: boolean
+}
+export const LoginForm = (props: LoginFormProps): JSX.Element => {
+  const { t } = useTranslation();
+  const router = useRouter();
+  const { data: csrfToken } = useCsrfToken();
 
+  const {
+    isLocalStrategySetup, isLdapStrategySetup, isPasswordResetEnabled, isRegistrationEnabled,
+    isEmailAuthenticationEnabled, registrationMode, registrationWhiteList, isMailerSetup,
+  } = props;
+  const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
+  // const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
+  const isSomeExternalAuthEnabled = true;
+
+  // states
+  const [isRegistering, setIsRegistering] = useState(false);
+  const [username, setUsername] = useState('');
+  const [name, setName] = useState('');
+  const [email, setEmail] = useState('');
+  const [password, setPassword] = useState('');
+  const [registerErrors, setRegisterErrors] = useState<Error[]>([]);
+
+  useEffect(() => {
     const { hash } = window.location;
     if (hash === '#register') {
-      this.state.isRegistering = true;
+      setIsRegistering(true);
     }
-  }
-
-  switchForm() {
-    this.setState({ isRegistering: !this.state.isRegistering });
-  }
+  }, []);
 
-  handleLoginWithExternalAuth(e) {
+  // functions
+  const handleLoginWithExternalAuth = useCallback((e) => {
     const auth = e.currentTarget.id;
-    window.location.href = `/passport/${auth}`;
-  }
 
-  renderLocalOrLdapLoginForm() {
-    const { t, csrfToken, isLdapStrategySetup } = this.props;
+    window.location.href = `/passport/${auth}`;
+  }, []);
+  const renderLocalOrLdapLoginForm = useCallback(() => {
+    const { isLdapStrategySetup } = props;
 
     return (
       <form role="form" action="/login" method="post">
@@ -79,10 +99,8 @@ class LoginForm extends React.Component {
         </div>
       </form>
     );
-  }
-
-  renderExternalAuthInput(auth) {
-    const { t } = this.props;
+  }, [csrfToken, props, t]);
+  const renderExternalAuthInput = useCallback((auth) => {
     const authIconNames = {
       google: 'google',
       github: 'github',
@@ -95,7 +113,7 @@ class LoginForm extends React.Component {
 
     return (
       <div key={auth} className="col-6 my-2">
-        <button type="button" className="btn btn-fill rounded-0" id={auth} onClick={this.handleLoginWithExternalAuth}>
+        <button type="button" className="btn btn-fill rounded-0" id={auth} onClick={handleLoginWithExternalAuth}>
           <div className="eff"></div>
           <span className="btn-label">
             <i className={`fa fa-${authIconNames[auth]}`}></i>
@@ -105,10 +123,9 @@ class LoginForm extends React.Component {
         <div className="small text-right">by {auth} Account</div>
       </div>
     );
-  }
-
-  renderExternalAuthLoginForm() {
-    const { isLocalStrategySetup, isLdapStrategySetup, objOfIsExternalAuthEnableds } = this.props;
+  }, [handleLoginWithExternalAuth, t]);
+  const renderExternalAuthLoginForm = useCallback(() => {
+    const { isLocalStrategySetup, isLdapStrategySetup, objOfIsExternalAuthEnableds } = props;
     const isExternalAuthCollapsible = isLocalStrategySetup || isLdapStrategySetup;
     const collapsibleClass = isExternalAuthCollapsible ? 'collapse collapse-external-auth' : '';
 
@@ -121,7 +138,7 @@ class LoginForm extends React.Component {
                 if (!objOfIsExternalAuthEnableds[auth]) {
                   return;
                 }
-                return this.renderExternalAuthInput(auth);
+                return renderExternalAuthInput(auth);
               })}
             </div>
           </div>
@@ -140,22 +157,42 @@ class LoginForm extends React.Component {
         </div>
       </>
     );
-  }
+  }, [props, renderExternalAuthInput]);
 
-  renderRegisterForm() {
-    const {
-      t,
-      // appContainer,
-      csrfToken,
-      isEmailAuthenticationEnabled,
+  const handleRegisterFormSubmit = useCallback(async(e, requestPath) => {
+    e.preventDefault();
+
+    const registerForm = {
       username,
       name,
       email,
-      registrationMode,
-      registrationWhiteList,
-      isMailerSetup,
-    } = this.props;
+      password,
+    };
+    try {
+      const res = await apiv3Post(requestPath, { registerForm });
+      const { redirectTo } = res.data;
+      router.push(redirectTo);
+    }
+    catch (err) {
+      // Execute if error exists
+      if (err != null || err.length > 0) {
+        setRegisterErrors(err);
+      }
+    }
+    return;
+  }, [email, name, password, router, username]);
+
+  const resetRegisterErrors = useCallback(() => {
+    if (registerErrors.length === 0) return;
+    setRegisterErrors([]);
+  }, [registerErrors.length]);
+
+  const switchForm = useCallback(() => {
+    setIsRegistering(!isRegistering);
+    resetRegisterErrors();
+  }, [isRegistering, resetRegisterErrors]);
 
+  const renderRegisterForm = useCallback(() => {
     let registerAction = '/register';
 
     let submitText = t('Sign up');
@@ -179,7 +216,21 @@ class LoginForm extends React.Component {
           </p>
         )}
 
-        <form role="form" action={registerAction} method="post" id="register-form">
+        {
+          registerErrors != null && registerErrors.length > 0 && (
+            <p className="alert alert-danger">
+              {registerErrors.map((err, index) => {
+                return (
+                  <span key={index}>
+                    {t(`message.${err.message}`)}<br/>
+                  </span>
+                );
+              })}
+            </p>
+          )
+        }
+
+        <form role="form" onSubmit={e => handleRegisterFormSubmit(e, registerAction) } id="register-form">
 
           {!isEmailAuthenticationEnabled && (
             <div>
@@ -189,12 +240,14 @@ class LoginForm extends React.Component {
                     <i className="icon-user"></i>
                   </span>
                 </div>
+                {/* username */}
                 <input
                   type="text"
                   className="form-control rounded-0"
+                  onChange={(e) => { setUsername(e.target.value) }}
                   placeholder={t('User ID')}
-                  name="registerForm[username]"
-                  defaultValue={username}
+                  name="username"
+                  defaultValue={props.username}
                   required
                 />
               </div>
@@ -207,7 +260,14 @@ class LoginForm extends React.Component {
                     <i className="icon-tag"></i>
                   </span>
                 </div>
-                <input type="text" className="form-control rounded-0" placeholder={t('Name')} name="registerForm[name]" defaultValue={name} required />
+                {/* name */}
+                <input type="text"
+                  className="form-control rounded-0"
+                  onChange={(e) => { setName(e.target.value) }}
+                  placeholder={t('Name')}
+                  name="name"
+                  defaultValue={props.name}
+                  required />
               </div>
             </div>
           )}
@@ -218,7 +278,15 @@ class LoginForm extends React.Component {
                 <i className="icon-envelope"></i>
               </span>
             </div>
-            <input type="email" className="form-control rounded-0" placeholder={t('Email')} name="registerForm[email]" defaultValue={email} required />
+            {/* email */}
+            <input type="email"
+              className="form-control rounded-0"
+              onChange={(e) => { setEmail(e.target.value) }}
+              placeholder={t('Email')}
+              name="email"
+              defaultValue={props.email}
+              required
+            />
           </div>
 
           {registrationWhiteList.length > 0 && (
@@ -244,14 +312,24 @@ class LoginForm extends React.Component {
                     <i className="icon-lock"></i>
                   </span>
                 </div>
-                <input type="password" className="form-control rounded-0" placeholder={t('Password')} name="registerForm[password]" required />
+                {/* Password */}
+                <input type="password"
+                  className="form-control rounded-0"
+                  onChange={(e) => { setPassword(e.target.value) }}
+                  placeholder={t('Password')}
+                  name="password"
+                  required />
               </div>
             </div>
           )}
 
+          {/* Sign up button (submit) */}
           <div className="input-group justify-content-center my-4">
-            <input type="hidden" name="_csrf" value={csrfToken} />
-            <button type="submit" className="btn btn-fill rounded-0" id="register" disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}>
+            <button
+              className="btn btn-fill rounded-0"
+              id="register"
+              disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}
+            >
               <div className="eff"></div>
               <span className="btn-label">
                 <i className="icon-user-follow"></i>
@@ -265,7 +343,7 @@ class LoginForm extends React.Component {
 
         <div className="row">
           <div className="text-right col-12 mt-2 py-2">
-            <a href="#login" id="login" className="link-switch" onClick={this.switchForm}>
+            <a href="#login" id="login" className="link-switch" onClick={switchForm}>
               <i className="icon-fw icon-login"></i>
               {t('Sign in is here')}
             </a>
@@ -273,93 +351,45 @@ class LoginForm extends React.Component {
         </div>
       </React.Fragment>
     );
-  }
-
-  render() {
-    const {
-      t,
-      isLocalStrategySetup,
-      isLdapStrategySetup,
-      isRegistrationEnabled,
-      isPasswordResetEnabled,
-      objOfIsExternalAuthEnableds,
-    } = this.props;
-
-
-    const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
-    // const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
-    const isSomeExternalAuthEnabled = true;
-
-    return (
-      <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
-        <div className="row mx-0">
-          <div className="col-12">
-            <ReactCardFlip isFlipped={this.state.isRegistering} flipDirection="horizontal" cardZIndex="3">
-              <div className="front">
-                {isLocalOrLdapStrategiesEnabled && this.renderLocalOrLdapLoginForm()}
-                {isSomeExternalAuthEnabled && this.renderExternalAuthLoginForm()}
-                {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
-                  <div className="text-right mb-2">
-                    <a href="/forgot-password" className="d-block link-switch">
-                      <i className="icon-key"></i> {t('forgot_password.forgot_password')}
-                    </a>
-                  </div>
-                )}
-                {isRegistrationEnabled && (
-                  <div className="text-right mb-2">
-                    <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
-                      <i className="ti ti-check-box"></i> {t('Sign up is here')}
-                    </a>
-                  </div>
-                )}
-              </div>
-              <div className="back">
-                {isRegistrationEnabled && this.renderRegisterForm()}
-              </div>
-            </ReactCardFlip>
-          </div>
+  }, [handleRegisterFormSubmit, isEmailAuthenticationEnabled, isMailerSetup,
+      props.email, props.name, props.username,
+      registerErrors, registrationMode, registrationWhiteList, switchForm, t]);
+
+  return (
+    <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
+      <div className="row mx-0">
+        <div className="col-12">
+          <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">
+            <div className="front">
+              {isLocalOrLdapStrategiesEnabled && renderLocalOrLdapLoginForm()}
+              {isSomeExternalAuthEnabled && renderExternalAuthLoginForm()}
+              {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
+                <div className="text-right mb-2">
+                  <a href="/forgot-password" className="d-block link-switch">
+                    <i className="icon-key"></i> {t('forgot_password.forgot_password')}
+                  </a>
+                </div>
+              )}
+              {/* Sign up link */}
+              {isRegistrationEnabled && (
+                <div className="text-right mb-2">
+                  <a href="#register" id="register" className="link-switch" onClick={switchForm}>
+                    <i className="ti ti-check-box"></i> {t('Sign up is here')}
+                  </a>
+                </div>
+              )}
+            </div>
+            <div className="back">
+              {/* Register form for /login#register */}
+              {isRegistrationEnabled && renderRegisterForm()}
+            </div>
+          </ReactCardFlip>
         </div>
-        <a href="https://growi.org" className="link-growi-org pl-3">
-          <span className="growi">GROWI</span>.<span className="org">ORG</span>
-        </a>
       </div>
-    );
-  }
-
-}
+      <a href="https://growi.org" className="link-growi-org pl-3">
+        <span className="growi">GROWI</span>.<span className="org">ORG</span>
+      </a>
+    </div>
+  );
 
-LoginForm.propTypes = {
-  // i18next
-  t: PropTypes.func.isRequired,
-  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  csrfToken: PropTypes.string,
-  isRegistering: PropTypes.bool,
-  username: PropTypes.string,
-  name: PropTypes.string,
-  email: PropTypes.string,
-  isRegistrationEnabled: PropTypes.bool,
-  registrationMode: PropTypes.string,
-  registrationWhiteList: PropTypes.array,
-  isPasswordResetEnabled: PropTypes.bool,
-  isEmailAuthenticationEnabled: PropTypes.bool,
-  isLocalStrategySetup: PropTypes.bool,
-  isLdapStrategySetup: PropTypes.bool,
-  objOfIsExternalAuthEnableds: PropTypes.object,
-  isMailerSetup: PropTypes.bool,
 };
-
-const LoginFormWrapperFC = (props) => {
-  const { t } = useTranslation();
-  const { data: csrfToken } = useCsrfToken();
-
-  return <LoginForm t={t} csrfToken={csrfToken} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-// const LoginFormWrapper = withUnstatedContainers(LoginFormWrapperFC, [AppContainer]);
-
-// export default LoginForm;
-export default LoginFormWrapperFC;

+ 17 - 25
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -180,7 +180,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
-  const { data: isNotFound } = useIsNotFound();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
@@ -296,14 +295,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
 
   const ControlComponents = useCallback(() => {
-    if (currentPage == null || pageId == null) {
-      return <></>;
-    }
-
-    function onPageEditorModeButtonClicked(viewType) {
-      mutateEditorMode(viewType);
-    }
-
     const additionalMenuItemsRenderer = () => {
       if (revisionId == null || pageId == null) {
         return <></>;
@@ -323,24 +314,26 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
           { isViewMode && (
             <div className="h-50 w-100">
-              <SubNavButtons
-                isCompactMode={isCompactMode}
-                pageId={pageId}
-                revisionId={revisionId}
-                shareLinkId={shareLinkId}
-                path={path}
-                disableSeenUserInfoPopover={isSharedUser}
-                showPageControlDropdown={isAbleToShowPageManagement}
-                additionalMenuItemRenderer={additionalMenuItemsRenderer}
-                onClickDuplicateMenuItem={duplicateItemClickedHandler}
-                onClickRenameMenuItem={renameItemClickedHandler}
-                onClickDeleteMenuItem={deleteItemClickedHandler}
-              />
+              { pageId != null && (
+                <SubNavButtons
+                  isCompactMode={isCompactMode}
+                  pageId={pageId}
+                  revisionId={revisionId}
+                  shareLinkId={shareLinkId}
+                  path={path}
+                  disableSeenUserInfoPopover={isSharedUser}
+                  showPageControlDropdown={isAbleToShowPageManagement}
+                  additionalMenuItemRenderer={additionalMenuItemsRenderer}
+                  onClickDuplicateMenuItem={duplicateItemClickedHandler}
+                  onClickRenameMenuItem={renameItemClickedHandler}
+                  onClickDeleteMenuItem={deleteItemClickedHandler}
+                />
+              ) }
             </div>
           ) }
           {isAbleToShowPageEditorModeManager && (
             <PageEditorModeManager
-              onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
+              onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
               isBtnDisabled={isGuestUser}
               editorMode={editorMode}
             />
@@ -356,7 +349,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       </>
     );
   // eslint-disable-next-line max-len
-  }, [currentPage, currentUser, pageId, revisionId, shareLinkId, path, editorMode, isCompactMode, isViewMode, isSharedUser, isAbleToShowPageManagement, isAbleToShowPageEditorModeManager, isLinkSharingDisabled, isGuestUser, isPageTemplateModalShown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, mutateEditorMode, templateMenuItemClickHandler]);
+  }, [currentUser, pageId, revisionId, shareLinkId, path, editorMode, isCompactMode, isViewMode, isSharedUser, isAbleToShowPageManagement, isAbleToShowPageEditorModeManager, isLinkSharingDisabled, isGuestUser, isPageTemplateModalShown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, mutateEditorMode, templateMenuItemClickHandler]);
 
   if (currentPathname == null) {
     return <></>;
@@ -375,7 +368,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       isGuestUser={isGuestUser}
       isDrawerMode={isDrawerMode}
       isCompactMode={isCompactMode}
-      isNotFound={isNotFound}
       tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
       tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
       controls={ControlComponents}

+ 15 - 18
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -35,7 +35,6 @@ export type GrowiSubNavigationProps = {
   isGuestUser?: boolean,
   isDrawerMode?: boolean,
   isCompactMode?: boolean,
-  isNotFound?: boolean,
   tags?: string[],
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void> | void,
   controls: React.FunctionComponent,
@@ -49,7 +48,7 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
   const {
     page,
     showDrawerToggler, showTagLabel, showPageAuthors,
-    isGuestUser, isDrawerMode, isCompactMode, isNotFound,
+    isGuestUser, isDrawerMode, isCompactMode,
     tags, tagsUpdatedHandler,
     controls: Controls,
     additionalClasses = [],
@@ -88,22 +87,20 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
         </div>
       </div>
       {/* Right side. isNotFound for avoid flicker when called ForbiddenPage.tsx */}
-      { !isNotFound && (
-        <div className="d-flex">
-          <Controls />
-          {/* Page Authors */}
-          { (showPageAuthors && !isCompactMode) && (
-            <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
-              <li className="pb-1">
-                <AuthorInfo user={creator as IUser} date={createdAt} locate="subnav" />
-              </li>
-              <li className="mt-1 pt-1 border-top">
-                <AuthorInfo user={lastUpdateUser as IUser} date={updatedAt} mode="update" locate="subnav" />
-              </li>
-            </ul>
-          ) }
-        </div>
-      ) }
+      <div className="d-flex">
+        <Controls />
+        {/* Page Authors */}
+        { (showPageAuthors && !isCompactMode) && (
+          <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
+            <li className="pb-1">
+              <AuthorInfo user={creator as IUser} date={createdAt} locate="subnav" />
+            </li>
+            <li className="mt-1 pt-1 border-top">
+              <AuthorInfo user={lastUpdateUser as IUser} date={updatedAt} mode="update" locate="subnav" />
+            </li>
+          </ul>
+        ) }
+      </div>
     </div>
   );
 };

+ 1 - 12
packages/app/src/components/Page.tsx

@@ -8,10 +8,9 @@ import dynamic from 'next/dynamic';
 
 import { HtmlElementNode } from 'rehype-toc';
 
-import { blinkSectionHeaderAtBoot } from '~/client/util/blink-section-header';
 // import { getOptionsToSave } from '~/client/util/editor';
 import {
-  useIsGuestUser, useIsBlinkedHeaderAtBoot, useCurrentPageTocNode,
+  useIsGuestUser, useCurrentPageTocNode,
 } from '~/stores/context';
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -210,20 +209,10 @@ export const Page = (props) => {
   const { data: pageTags } = usePageTagsForEditors(null); // TODO: pass pageId
   const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
-  const { data: isBlinkedAtBoot, mutate: mutateBlinkedAtBoot } = useIsBlinkedHeaderAtBoot();
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
 
   const pageRef = useRef(null);
 
-  useEffect(() => {
-    if (isBlinkedAtBoot) {
-      return;
-    }
-
-    blinkSectionHeaderAtBoot();
-    mutateBlinkedAtBoot(true);
-  }, [isBlinkedAtBoot, mutateBlinkedAtBoot]);
-
   useEffect(() => {
     mutateCurrentPageTocNode(tocRef.current);
   // eslint-disable-next-line react-hooks/exhaustive-deps

+ 6 - 6
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -6,7 +6,7 @@ import dynamic from 'next/dynamic';
 
 // import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, usePageUser, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -16,7 +16,6 @@ import CountBadge from '../Common/CountBadge';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import PageListIcon from '../Icons/PageListIcon';
 import { Page } from '../Page';
-// import PageEditorByHackmd from '../PageEditorByHackmd';
 import TableOfContents from '../TableOfContents';
 import { UserInfoProps } from '../User/UserInfo';
 
@@ -24,10 +23,11 @@ import { UserInfoProps } from '../User/UserInfo';
 import styles from './DisplaySwitcher.module.scss';
 
 
-const { isTopPage } = pagePathUtils;
+const { isTopPage, isUsersTopPage } = pagePathUtils;
 
 
 const PageEditor = dynamic(() => import('../PageEditor'), { ssr: false });
+const PageEditorByHackmd = dynamic(() => import('../PageEditorByHackmd').then(mod => mod.PageEditorByHackmd), { ssr: false });
 const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
 const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
 const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons'), { ssr: false });
@@ -41,19 +41,19 @@ const PageView = React.memo((): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
-  const { data: isUserPage } = useIsUserPage();
   const { data: pageUser } = usePageUser();
   const { data: isNotFound } = useIsNotFound();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
   const isTopPagePath = isTopPage(currentPagePath ?? '');
+  const isUsersTopPagePath = isUsersTopPage(currentPagePath ?? '');
 
   return (
     <div className="d-flex flex-column flex-lg-row">
 
       <div className="flex-grow-1 flex-basis-0 mw-0">
-        { isUserPage && pageUser != null && <UserInfo pageUser={pageUser} />}
+        { pageUser != null && isUsersTopPagePath && <UserInfo pageUser={pageUser} />}
         { !isNotFound && <Page /> }
         { isNotFound && <NotFoundPage /> }
       </div>
@@ -145,7 +145,7 @@ const DisplaySwitcher = React.memo((): JSX.Element => {
           isEditable
             ? (
               <div id="page-editor-with-hackmd">
-                {/* <PageEditorByHackmd /> */}
+                <PageEditorByHackmd />
               </div>
             )
             : <></>

+ 0 - 1
packages/app/src/components/Page/RevisionLoader.jsx

@@ -111,7 +111,6 @@ class RevisionLoader extends React.Component {
       <RevisionRenderer
         rendererOptions={this.props.rendererOptions}
         markdown={markdown}
-        highlightKeywords={this.props.highlightKeywords}
       />
     );
   }

+ 1 - 75
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -2,97 +2,23 @@ import React from 'react';
 
 import ReactMarkdown from 'react-markdown';
 
-import { blinkElem } from '~/client/util/blink-section-header';
-import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
-import { CustomWindow } from '~/interfaces/global';
 import { RendererOptions } from '~/services/renderer/renderer';
-import { useCurrentPathname, useInterceptorManager } from '~/stores/context';
-import { useEditorSettings } from '~/stores/editor';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('components:Page:RevisionRenderer');
 
 
-// function getHighlightedBody(body: string, _keywords: string | string[]): string {
-//   const normalizedKeywordsArray: string[] = [];
-
-//   const keywords = (typeof _keywords === 'string') ? [_keywords] : _keywords;
-
-//   if (keywords.length === 0) {
-//     return body;
-//   }
-
-//   // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
-//   // Separate keywords
-//   // - Surrounded by double quotation
-//   // - Split by both full-width and half-width spaces
-//   // [...keywords.match(/"[^"]+"|[^\u{20}\u{3000}]+/ug)].forEach((keyword, i) => {
-//   keywords.forEach((keyword, i) => {
-//     if (keyword === '') {
-//       return;
-//     }
-//     const k = keyword
-//       .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex operators
-//       .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
-//     normalizedKeywordsArray.push(k);
-//   });
-
-//   const normalizedKeywords = `(${normalizedKeywordsArray.join('|')})`;
-//   const keywordRegxp = new RegExp(`${normalizedKeywords}(?!(.*?"))`, 'ig'); // prior https://regex101.com/r/oX7dq5/1
-//   let keywordRegexp2 = keywordRegxp;
-
-//   // for non-chrome browsers compatibility
-//   try {
-// eslint-disable-next-line regex/invalid, max-len
-//     keywordRegexp2 = new RegExp(`(?<!<)${normalizedKeywords}(?!(.*?("|>)))`, 'ig'); // inferior (this doesn't work well when html tags exist a lot) https://regex101.com/r/Dfi61F/1
-//   }
-//   catch (err) {
-//     logger.debug('Failed to initialize regex:', err);
-//   }
-
-//   const highlighter = (str) => { return str.replace(keywordRegxp, '<em class="highlighted-keyword">$&</em>') }; // prior
-//   const highlighter2 = (str) => { return str.replace(keywordRegexp2, '<em class="highlighted-keyword">$&</em>') }; // inferior
-
-//   const insideTagRegex = /<[^<>]*>/g;
-//   const betweenTagRegex = />([^<>]*)</g; // use (group) to ignore >< around
-
-//   const insideTagStrs = body.match(insideTagRegex);
-//   const betweenTagMatches = Array.from(body.matchAll(betweenTagRegex));
-
-//   let returnBody = body;
-//   const isSafeHtml = insideTagStrs?.length === betweenTagMatches.length + 1; // to check whether is safe to join
-//   if (isSafeHtml) {
-//     // highlight
-//     const betweenTagStrs: string[] = betweenTagMatches.map(match => highlighter(match[1])); // get only grouped part (exclude >< around)
-
-//     const arr: string[] = [];
-//     insideTagStrs.forEach((str, i) => {
-//       arr.push(str);
-//       arr.push(betweenTagStrs[i]);
-//     });
-//     returnBody = arr.join('');
-//   }
-//   else {
-//     // inferior highlighter
-//     returnBody = highlighter2(body);
-//   }
-
-//   return returnBody;
-// }
-
-
 type Props = {
   rendererOptions: RendererOptions,
   markdown: string,
-  highlightKeywords?: string | string[],
   additionalClassName?: string,
 }
 
 const RevisionRenderer = React.memo((props: Props): JSX.Element => {
 
   const {
-    rendererOptions, markdown, highlightKeywords, additionalClassName,
+    rendererOptions, markdown, additionalClassName,
   } = props;
 
   return (

+ 62 - 65
packages/app/src/components/PageAttachment.tsx

@@ -1,65 +1,49 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, {
+  useCallback, useMemo, useState,
+} from 'react';
 
-import { useTranslation } from 'next-i18next';
+import { HasObjectId, IAttachment } from '@growi/core';
 
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useEditingMarkdown, useCurrentPageId, useIsGuestUser } from '~/stores/context';
 
-import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
-import PageAttachmentList from './PageAttachment/PageAttachmentList';
+import { DeleteAttachmentModal } from './PageAttachment/DeleteAttachmentModal';
+import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
 import PaginationWrapper from './PaginationWrapper';
 
 // Utility
-const checkIfFileInUse = (markdown: string, attachment) => {
-  return markdown.match(attachment._id);
-};
-
-// Custom hook that handles processes related to inUseAttachments
-const useInUseAttachments = (attachments) => {
-  const { data: markdown } = useEditingMarkdown();
-  const [inUse, setInUse] = useState<any>({});
-
-  // Update inUse when either of attachments or markdown is updated
-  useEffect(() => {
-    if (markdown == null) {
-      return;
-    }
-
-    const newInUse = {};
-
-    for (const attachment of attachments) {
-      newInUse[attachment._id] = checkIfFileInUse(markdown, attachment);
-    }
-
-    setInUse(newInUse);
-  }, [attachments, markdown]);
-
-  return inUse;
+const checkIfFileInUse = (markdown: string, attachment): boolean => {
+  return markdown.indexOf(attachment._id) >= 0;
 };
 
 const PageAttachment = (): JSX.Element => {
-  const { t } = useTranslation();
-
   // Static SWRs
   const { data: pageId } = useCurrentPageId();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: markdown } = useEditingMarkdown();
 
   // States
   const [pageNumber, setPageNumber] = useState(1);
-  const [attachmentToDelete, setAttachmentToDelete] = useState<any>(undefined);
+  const [attachmentToDelete, setAttachmentToDelete] = useState<(IAttachment & HasObjectId) | null>(null);
   const [deleting, setDeleting] = useState(false);
   const [deleteError, setDeleteError] = useState('');
 
   // SWRs
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
-  const {
-    attachments = [],
-    totalAttachments = 0,
-    limit,
-  } = dataAttachments ?? {};
 
   // Custom hooks
-  const inUseAttachments = useInUseAttachments(attachments);
+  const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
+    if (markdown == null || dataAttachments == null) {
+      return undefined;
+    }
+
+    const attachmentEntries = dataAttachments.attachments
+      .map((attachment) => {
+        return [attachment._id, checkIfFileInUse(markdown, attachment)];
+      });
+
+    return Object.fromEntries(attachmentEntries);
+  }, [dataAttachments, markdown]);
 
   // Methods
   const onChangePageHandler = useCallback((newPageNumber: number) => {
@@ -70,7 +54,7 @@ const PageAttachment = (): JSX.Element => {
     setAttachmentToDelete(attachment);
   }, []);
 
-  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment) => {
+  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment: IAttachment & HasObjectId) => {
     setDeleting(true);
 
     try {
@@ -91,22 +75,32 @@ const PageAttachment = (): JSX.Element => {
   }, []);
 
   // Renderers
-  const renderDeleteAttachmentModal = useCallback(() => {
-    if (isGuestUser) {
-      return <></>;
-    }
-
-    if (attachments.length === 0) {
+  const renderPageAttachmentList = useCallback(() => {
+    if (dataAttachments == null || inUseAttachmentsMap == null) {
       return (
-        <div data-testid="page-attachment">
-          {t('No_attachments_yet')}
+        <div className="text-muted text-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
         </div>
       );
     }
 
-    let deleteInUse = null;
-    if (attachmentToDelete != null) {
-      deleteInUse = inUseAttachments[attachmentToDelete._id] || false;
+    return (
+      <PageAttachmentList
+        attachments={dataAttachments.attachments}
+        inUse={inUseAttachmentsMap}
+        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
+        isUserLoggedIn={!isGuestUser}
+      />
+    );
+  }, [dataAttachments, inUseAttachmentsMap, isGuestUser, onAttachmentDeleteClicked]);
+
+  const renderDeleteAttachmentModal = useCallback(() => {
+    if (isGuestUser) {
+      return <></>;
+    }
+
+    if (dataAttachments == null || dataAttachments.attachments.length === 0 || attachmentToDelete == null) {
+      return <></>;
     }
 
     const isOpen = attachmentToDelete != null;
@@ -114,36 +108,39 @@ const PageAttachment = (): JSX.Element => {
     return (
       <DeleteAttachmentModal
         isOpen={isOpen}
-        animation="false"
         toggle={onToggleHandler}
         attachmentToDelete={attachmentToDelete}
-        inUse={deleteInUse}
         deleting={deleting}
         deleteError={deleteError}
         onAttachmentDeleteClickedConfirm={onAttachmentDeleteClickedConfirmHandler}
       />
     );
   // eslint-disable-next-line max-len
-  }, [attachmentToDelete, attachments.length, deleteError, deleting, inUseAttachments, isGuestUser, onAttachmentDeleteClickedConfirmHandler, onToggleHandler, t]);
+  }, [attachmentToDelete, dataAttachments, deleteError, deleting, isGuestUser, onAttachmentDeleteClickedConfirmHandler, onToggleHandler]);
 
-  return (
-    <div data-testid="page-attachment">
-      <PageAttachmentList
-        attachments={attachments}
-        inUse={inUseAttachments}
-        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
-        isUserLoggedIn={!isGuestUser}
-      />
-
-      {renderDeleteAttachmentModal()}
+  const renderPaginationWrapper = useCallback(() => {
+    if (dataAttachments == null || dataAttachments.attachments.length === 0) {
+      return <></>;
+    }
 
+    return (
       <PaginationWrapper
         activePage={pageNumber}
         changePage={onChangePageHandler}
-        totalItemsCount={totalAttachments}
-        pagingLimit={limit}
+        totalItemsCount={dataAttachments.totalAttachments}
+        pagingLimit={dataAttachments.limit}
         align="center"
       />
+    );
+  }, [dataAttachments, onChangePageHandler, pageNumber]);
+
+  return (
+    <div data-testid="page-attachment">
+      {renderPageAttachmentList()}
+
+      {renderDeleteAttachmentModal()}
+
+      {renderPaginationWrapper()}
     </div>
   );
 };

+ 0 - 97
packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx

@@ -1,97 +0,0 @@
-/* eslint-disable react/prop-types */
-import React from 'react';
-
-import {
-  Button,
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { UserPicture } from '@growi/ui';
-import Username from '../User/Username';
-
-export default class DeleteAttachmentModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this._onDeleteConfirm = this._onDeleteConfirm.bind(this);
-  }
-
-  _onDeleteConfirm() {
-    this.props.onAttachmentDeleteClickedConfirm(this.props.attachmentToDelete);
-  }
-
-  iconNameByFormat(format) {
-    if (format.match(/image\/.+/i)) {
-      return 'icon-picture';
-    }
-
-    return 'icon-doc';
-  }
-
-  renderByFileFormat(attachment) {
-    const content = (attachment.fileFormat.match(/image\/.+/i))
-      ? <img src={attachment.filePathProxied} alt="deleting image" />
-      : '';
-
-
-    return (
-      <div className="attachment-delete-image">
-        <p>
-          <i className={this.iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
-        </p>
-        <p>
-          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
-        </p>
-        {content}
-      </div>
-    );
-  }
-
-  render() {
-    const attachment = this.props.attachmentToDelete;
-    if (attachment === null) {
-      return null;
-    }
-
-    const props = Object.assign({}, this.props);
-    delete props.onAttachmentDeleteClickedConfirm;
-    delete props.attachmentToDelete;
-    delete props.inUse;
-    delete props.deleting;
-    delete props.deleteError;
-
-    let deletingIndicator = '';
-    if (this.props.deleting) {
-      deletingIndicator = <div className="speeding-wheel-sm"></div>;
-    }
-    if (this.props.deleteError) {
-      deletingIndicator = <span>{this.props.deleteError}</span>;
-    }
-
-    const renderAttachment = this.renderByFileFormat(attachment);
-
-    return (
-      <Modal {...props} className="attachment-delete-modal" bssize="large" aria-labelledby="contained-modal-title-lg">
-        <ModalHeader tag="h4" toggle={this.props.toggle} className="bg-danger text-light">
-          <span id="contained-modal-title-lg">Delete attachment?</span>
-        </ModalHeader>
-        <ModalBody>
-          {renderAttachment}
-        </ModalBody>
-        <ModalFooter>
-          <div className="mr-3 d-inline-block">
-            {deletingIndicator}
-          </div>
-          <Button
-            color="danger"
-            onClick={this._onDeleteConfirm}
-            disabled={this.props.deleting}
-          >Delete!
-          </Button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}

+ 98 - 0
packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -0,0 +1,98 @@
+/* eslint-disable react/prop-types */
+import React, { useCallback } from 'react';
+
+import { HasObjectId, IAttachment } from '@growi/core';
+import { UserPicture } from '@growi/ui';
+import {
+  Button,
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import Username from '../User/Username';
+
+
+function iconNameByFormat(format: string): string {
+  if (format.match(/image\/.+/i)) {
+    return 'icon-picture';
+  }
+
+  return 'icon-doc';
+}
+
+
+type Props = {
+  isOpen: boolean,
+  toggle: () => void,
+  attachmentToDelete: IAttachment & HasObjectId | null,
+  deleting: boolean,
+  deleteError: string,
+  onAttachmentDeleteClickedConfirm?: (attachment: IAttachment & HasObjectId) => Promise<void>,
+}
+
+export const DeleteAttachmentModal = (props: Props): JSX.Element => {
+
+  const {
+    isOpen, toggle,
+    attachmentToDelete, deleting, deleteError,
+    onAttachmentDeleteClickedConfirm,
+  } = props;
+
+  const onDeleteConfirm = useCallback(() => {
+    if (attachmentToDelete == null || onAttachmentDeleteClickedConfirm == null) {
+      return;
+    }
+    onAttachmentDeleteClickedConfirm(attachmentToDelete);
+  }, [attachmentToDelete, onAttachmentDeleteClickedConfirm]);
+
+  const renderByFileFormat = useCallback((attachment) => {
+    const content = (attachment.fileFormat.match(/image\/.+/i))
+      // eslint-disable-next-line @next/next/no-img-element
+      ? <img src={attachment.filePathProxied} alt="deleting image" />
+      : '';
+
+
+    return (
+      <div className="attachment-delete-image">
+        <p>
+          <i className={iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
+        </p>
+        <p>
+          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
+        </p>
+        {content}
+      </div>
+    );
+  }, []);
+
+  let deletingIndicator = <></>;
+  if (deleting) {
+    deletingIndicator = <div className="speeding-wheel-sm"></div>;
+  }
+  if (deleteError) {
+    deletingIndicator = <span>{deleteError}</span>;
+  }
+
+
+  return (
+    <Modal isOpen={isOpen} className="attachment-delete-modal" size="lg" aria-labelledby="contained-modal-title-lg" fade={false}>
+      <ModalHeader tag="h4" toggle={toggle} className="bg-danger text-light">
+        <span id="contained-modal-title-lg">Delete attachment?</span>
+      </ModalHeader>
+      <ModalBody>
+        {renderByFileFormat(attachmentToDelete)}
+      </ModalBody>
+      <ModalFooter>
+        <div className="mr-3 d-inline-block">
+          {deletingIndicator}
+        </div>
+        <Button
+          color="danger"
+          onClick={onDeleteConfirm}
+          disabled={deleting}
+        >Delete!
+        </Button>
+      </ModalFooter>
+    </Modal>
+  );
+
+};

+ 0 - 45
packages/app/src/components/PageAttachment/PageAttachmentList.jsx

@@ -1,45 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { Attachment } from '@growi/ui';
-
-export default class PageAttachmentList extends React.Component {
-
-  render() {
-    if (this.props.attachments <= 0) {
-      return null;
-    }
-
-    const attachmentList = this.props.attachments.map((attachment, idx) => {
-      return (
-        <Attachment
-          key={`page:attachment:${attachment._id}`}
-          attachment={attachment}
-          inUse={this.props.inUse[attachment._id] || false}
-          onAttachmentDeleteClicked={this.props.onAttachmentDeleteClicked}
-          isUserLoggedIn={this.props.isUserLoggedIn}
-        />
-      );
-    });
-
-    return (
-      <div>
-        <ul className="pl-2">
-          {attachmentList}
-        </ul>
-      </div>
-    );
-  }
-
-}
-
-PageAttachmentList.propTypes = {
-  attachments: PropTypes.arrayOf(PropTypes.object),
-  inUse: PropTypes.objectOf(PropTypes.bool),
-  onAttachmentDeleteClicked: PropTypes.func,
-  isUserLoggedIn: PropTypes.bool,
-};
-PageAttachmentList.defaultProps = {
-  attachments: [],
-  inUse: {},
-};

+ 47 - 0
packages/app/src/components/PageAttachment/PageAttachmentList.tsx

@@ -0,0 +1,47 @@
+import React from 'react';
+
+
+import { HasObjectId, IAttachment } from '@growi/core';
+import { Attachment } from '@growi/ui';
+import { useTranslation } from 'next-i18next';
+
+
+type Props = {
+  attachments: (IAttachment & HasObjectId)[],
+  inUse: { [id: string]: boolean },
+  onAttachmentDeleteClicked?: (attachment: IAttachment & HasObjectId) => void,
+  isUserLoggedIn?: boolean,
+}
+
+export const PageAttachmentList = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    attachments, inUse, onAttachmentDeleteClicked, isUserLoggedIn,
+  } = props;
+
+  if (attachments.length === 0) {
+    return <>{t('No_attachments_yet')}</>;
+  }
+
+  const attachmentList = attachments.map((attachment) => {
+    return (
+      <Attachment
+        key={`page:attachment:${attachment._id}`}
+        attachment={attachment}
+        inUse={inUse[attachment._id] || false}
+        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
+        isUserLoggedIn={isUserLoggedIn}
+      />
+    );
+  });
+
+  return (
+    <div>
+      <ul className="pl-2">
+        {attachmentList}
+      </ul>
+    </div>
+  );
+
+};

+ 13 - 27
packages/app/src/components/PageComment.tsx

@@ -1,5 +1,5 @@
 import React, {
-  FC, useEffect, useState, useMemo, memo, useCallback,
+  FC, useState, useMemo, memo, useCallback,
 } from 'react';
 
 import { IRevisionHasId, isPopulated, getIdForRef } from '@growi/core';
@@ -8,6 +8,8 @@ import { Button } from 'reactstrap';
 
 import { toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
+import { RendererOptions } from '~/services/renderer/renderer';
+import { useCommentForCurrentPageOptions } from '~/stores/renderer';
 
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
@@ -26,6 +28,7 @@ const DeleteCommentModal = dynamic<DeleteCommentModalProps>(
 );
 
 export type PageCommentProps = {
+  rendererOptions?: RendererOptions,
   pageId: string,
   revision: string | IRevisionHasId,
   currentUser: any,
@@ -38,46 +41,24 @@ export type PageCommentProps = {
 export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps): JSX.Element => {
 
   const {
+    rendererOptions: rendererOptionsByProps,
     pageId, revision, currentUser, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
+  const { data: rendererOptionsForCurrentPage } = useCommentForCurrentPageOptions();
 
   const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
   const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
-  const [formatedComments, setFormatedComments] = useState<ICommentHasIdList | null>(null);
   const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
 
-  const commentsFromOldest = useMemo(() => (formatedComments != null ? [...formatedComments].reverse() : null), [formatedComments]);
+  const commentsFromOldest = useMemo(() => (comments != null ? [...comments].reverse() : null), [comments]);
   const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
     () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
   );
   const allReplies = {};
 
-  const highlightComment = useCallback((comment: string):string => {
-    if (highlightKeywords == null) return comment;
-
-    let highlightedComment = '';
-    highlightKeywords.forEach((highlightKeyword) => {
-      highlightedComment = comment.replaceAll(highlightKeyword, '<em class="highlighted-keyword">$&</em>');
-    });
-    return highlightedComment;
-  }, [highlightKeywords]);
-
-  useEffect(() => {
-    if (comments != null) {
-      const preprocessedCommentList: string[] = comments.map((comment) => {
-        const highlightedComment: string = highlightComment(comment.comment);
-        return highlightedComment;
-      });
-      const preprocessedComments: ICommentHasIdList = comments.map((comment, index) => {
-        return { ...comment, comment: preprocessedCommentList[index] };
-      });
-      setFormatedComments(preprocessedComments);
-    }
-  }, [comments, highlightComment]);
-
   if (commentsFromOldest != null) {
     commentsFromOldest.forEach((comment) => {
       if (comment.replyTo != null) {
@@ -128,7 +109,9 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   let commentTitleClasses = 'border-bottom py-3 mb-3';
   commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
 
-  if (commentsFromOldest == null || commentsExceptReply == null) {
+  const rendererOptions = rendererOptionsByProps ?? rendererOptionsForCurrentPage;
+
+  if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
     if (hideIfEmpty) {
       return <></>;
     }
@@ -142,11 +125,13 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
   const generateCommentElement = (comment: ICommentHasId) => (
     <Comment
+      rendererOptions={rendererOptions}
       comment={comment}
       revisionId={revisionId}
       revisionCreatedAt={revisionCreatedAt as Date}
       currentUser={currentUser}
       isReadOnly={isReadOnly}
+      highlightKeywords={highlightKeywords}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
     />
@@ -154,6 +139,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
   const generateReplyCommentsElement = (replyComments: ICommentHasIdList) => (
     <ReplyComments
+      rendererOptions={rendererOptions}
       isReadOnly={isReadOnly}
       revisionId={revisionId}
       revisionCreatedAt={revisionCreatedAt as Date}

+ 5 - 3
packages/app/src/components/PageComment/Comment.tsx

@@ -7,7 +7,7 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { useCommentPreviewOptions } from '~/stores/renderer';
+import { RendererOptions } from '~/services/renderer/renderer';
 
 import { ICommentHasId } from '../../interfaces/comment';
 import FormattedDistanceDate from '../FormattedDistanceDate';
@@ -24,10 +24,12 @@ const CommentEditor = dynamic<CommentEditorProps>(() => import('./CommentEditor'
 
 type CommentProps = {
   comment: ICommentHasId,
+  rendererOptions: RendererOptions,
   revisionId: string,
   revisionCreatedAt: Date,
   currentUser: IUser,
   isReadOnly: boolean,
+  highlightKeywords?: string[],
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
 }
@@ -35,11 +37,11 @@ type CommentProps = {
 export const Comment = (props: CommentProps): JSX.Element => {
 
   const {
-    comment, revisionId, revisionCreatedAt, currentUser, isReadOnly, deleteBtnClicked, onComment,
+    comment, rendererOptions, revisionId, revisionCreatedAt, currentUser, isReadOnly,
+    deleteBtnClicked, onComment,
   } = props;
 
   const { t } = useTranslation();
-  const { data: rendererOptions } = useCommentPreviewOptions();
 
   const [markdown, setMarkdown] = useState('');
   const [isReEdit, setIsReEdit] = useState(false);

+ 7 - 1
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -232,7 +232,13 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
     const errorMessage = <span className="text-danger text-right mr-2">{error}</span>;
     const cancelButton = (
-      <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={cancelButtonClickedHandler}>
+      <Button
+        outline
+        color="danger"
+        size="xs"
+        className="btn btn-outline-danger rounded-pill"
+        onClick={cancelButtonClickedHandler}
+      >
         Cancel
       </Button>
     );

+ 8 - 1
packages/app/src/components/PageComment/ReplyComments.tsx

@@ -3,6 +3,8 @@ import React, { useState } from 'react';
 
 import { Collapse } from 'reactstrap';
 
+import { RendererOptions } from '~/services/renderer/renderer';
+
 import { ICommentHasId, ICommentHasIdList } from '../../interfaces/comment';
 import { IUser } from '../../interfaces/user';
 import { useIsAllReplyShown } from '../../stores/context';
@@ -13,11 +15,13 @@ import styles from './ReplyComments.module.scss';
 
 
 type ReplycommentsProps = {
+  rendererOptions: RendererOptions,
   isReadOnly: boolean,
   revisionId: string,
   revisionCreatedAt: Date,
   currentUser: IUser,
   replyList: ICommentHasIdList,
+  highlightKeywords?: string[],
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
 }
@@ -25,7 +29,8 @@ type ReplycommentsProps = {
 export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
   const {
-    isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList, deleteBtnClicked, onComment,
+    rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList, highlightKeywords,
+    deleteBtnClicked, onComment,
   } = props;
 
   const { data: isAllReplyShown } = useIsAllReplyShown();
@@ -36,11 +41,13 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
     return (
       <div key={reply._id} className={`${styles['page-comment-reply']} ml-4 ml-sm-5 mr-3`}>
         <Comment
+          rendererOptions={rendererOptions}
           comment={reply}
           revisionId={revisionId}
           revisionCreatedAt={revisionCreatedAt}
           currentUser={currentUser}
           isReadOnly={isReadOnly}
+          highlightKeywords={highlightKeywords}
           deleteBtnClicked={deleteBtnClicked}
           onComment={onComment}
         />

+ 5 - 0
packages/app/src/components/PageEditor/CodeMirrorEditor.module.scss

@@ -59,6 +59,11 @@
     pre.CodeMirror-line-like.CodeMirror-placeholder {
       color: bs.$text-muted;
     }
+
+    // overwrite .CodeMirror-scroll
+    .CodeMirror-scroll {
+      box-sizing: border-box;
+    }
   }
 
   // patch to fix https://github.com/codemirror/CodeMirror/issues/4089

+ 117 - 98
packages/app/src/components/PageEditorByHackmd.tsx

@@ -1,47 +1,54 @@
 import React, {
-  useCallback, useEffect, useRef, useState,
+  useCallback, useRef, useState, useEffect,
 } from 'react';
 
+import EventEmitter from 'events';
+
 import { useTranslation } from 'react-i18next';
 
 
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
+import { saveOrUpdate } from '~/client/services/page-operation';
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
-import { useCurrentPagePath, useCurrentPageId } from '~/stores/context';
+import {
+  useCurrentPagePath, useCurrentPageId, useHackmdUri, usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced,
+} from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
+import { useSWRxCurrentPage } from '~/stores/page';
 import {
+  EditorMode,
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 const logger = loggerFactory('growi:PageEditorByHackmd');
 
-type PageEditorByHackmdProps = {
-  appContainer: AppContainer,
-  pageContainer: PageContainer,
-};
+declare const globalEmitter: EventEmitter;
 
 type HackEditorRef = {
-  getValue: () => string
+  getValue: () => Promise<string>
 };
 
-const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
-  const { appContainer, pageContainer } = props; // wip
+export const PageEditorByHackmd = (): JSX.Element => {
 
   const { t } = useTranslation();
-  const { data: editorMode } = useEditorMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
+  const { data: currentPathname } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: pageId } = useCurrentPageId();
   const { data: pageTags, mutate: updatePageTagsForEditors } = usePageTagsForEditors(pageId);
   const { data: grant } = useSelectedGrant();
+  const { data: hackmdUri } = useHackmdUri();
+
+  // pageData
+  const { data: pageData, mutate: updatePageData } = useSWRxCurrentPage();
+  const revision = pageData?.revision;
 
   const slackChannels = slackChannelsData?.toString();
 
@@ -52,41 +59,61 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
   const [errorMessage, setErrorMessage] = useState('');
   const [errorReason, setErrorReason] = useState('');
 
+  // state from pageContainer
+  const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
+  const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
+  const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
+  const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
+  const [remoteRevisionId, setRemoteRevisionId] = useState(revision?._id); // initialize
+
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
+  const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
+    if (editorMode !== EditorMode.HackMD) {
+      return;
+    }
+
+    if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
+      return;
+    }
+
+    let optionsToSave;
+
+    const currentOptionsToSave = getOptionsToSave(
+      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+    );
+
+    if (opts != null) {
+      optionsToSave = Object.assign(currentOptionsToSave, {
+        ...opts,
+      });
+    }
+    else {
+      optionsToSave = currentOptionsToSave;
+    }
+
+    const markdown = await hackmdEditorRef.current.getValue();
+
+    await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+    await updatePageData();
+    mutateEditorMode(EditorMode.View);
+  }, [currentPagePath, currentPathname, editorMode, grant, isSlackEnabled, pageId, pageTags, revision, slackChannels, mutateEditorMode, updatePageData]);
+
+  // set handler to save and reload Page
   useEffect(() => {
-    const pageEditorByHackmdInstance = {
-      getMarkdown: () => {
-        if (!isInitialized) {
-          return Promise.reject(new Error(t('hackmd.not_initialized')));
-        }
-
-        if (hackmdEditorRef.current == null) { return }
-
-        return hackmdEditorRef.current.getValue();
-      },
-      reset: () => {
-        setIsInitialized(false);
-      },
-    };
-    // appContainer.registerComponentInstance('PageEditorByHackmd', pageEditorByHackmdInstance);
-  }, [appContainer, isInitialized, t]);
+    globalEmitter.on('saveAndReturnToView', saveAndReturnToViewHandler);
 
-  const getHackmdUri = useCallback(() => {
-    const envVars = appContainer.getConfig().env;
-    return envVars.HACKMD_URI;
-  }, [appContainer]);
+    return function cleanup() {
+      globalEmitter.removeListener('saveAndReturnToView', saveAndReturnToViewHandler);
+    };
+  }, [saveAndReturnToViewHandler]);
 
   const isResume = useCallback(() => {
-    const {
-      pageIdOnHackmd, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
-    } = pageContainer.state;
     const isPageExistsOnHackmd = (pageIdOnHackmd != null);
     return (isPageExistsOnHackmd && hasDraftOnHackmd) || isHackmdDraftUpdatingInRealtime;
-  }, [pageContainer.state]);
+  }, [hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime, pageIdOnHackmd]);
 
   const startToEdit = useCallback(async() => {
-    const hackmdUri = getHackmdUri();
 
     if (hackmdUri == null) {
       // do nothing
@@ -103,13 +130,11 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
         throw new Error(res.error);
       }
 
-      await pageContainer.setState({
-        pageIdOnHackmd: res.pageIdOnHackmd,
-        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
-      });
+      mutatePageIdOnHackmd(res.pageIdOnHackmd);
+      mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
     }
     catch (err) {
-      pageContainer.showErrorToastr(err);
+      toastError(err);
 
       setHasError(true);
       setErrorMessage('GROWI server failed to connect to HackMD.');
@@ -118,7 +143,7 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
 
     setIsInitialized(true);
     setIsInitializing(false);
-  }, [getHackmdUri, pageContainer, pageId]);
+  }, [pageId, hackmdUri, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced]);
 
   /**
    * Start to edit w/o any api request
@@ -128,7 +153,8 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
   }, []);
 
   const discardChanges = useCallback(async() => {
-    const { pageId } = pageContainer.state;
+
+    if (pageId == null) { return }
 
     try {
       const res = await apiPost<IResHackmdDiscard>('/hackmd.discard', { pageId });
@@ -137,89 +163,91 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
         throw new Error(res.error);
       }
 
-      pageContainer.setState({
-        isHackmdDraftUpdatingInRealtime: false,
-        hasDraftOnHackmd: false,
-        pageIdOnHackmd: res.pageIdOnHackmd,
-        remoteRevisionId: res.revisionIdHackmdSynced,
-        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
-      });
+      setIsHackmdDraftUpdatingInRealtime(false);
+      mutateHasDraftOnHackmd(false);
+      mutatePageIdOnHackmd(res.pageIdOnHackmd);
+      setRemoteRevisionId(res.revisionIdHackmdSynced);
+      mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
+
+
     }
     catch (err) {
       logger.error(err);
-      pageContainer.showErrorToastr(err);
+      toastError(err);
     }
-  }, [pageContainer]);
+  }, [setIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, pageId]);
 
   /**
    * save and update state of containers
    * @param {string} markdown
    */
   const onSaveWithShortcut = useCallback(async(markdown) => {
-    if (isSlackEnabled == null || grant == null || slackChannels == null) { return }
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? []);
+    if (
+      isSlackEnabled == null || grant == null || slackChannels == null || pageId == null || revisionIdHackmdSynced == null || currentPathname == null
+    ) { return }
+    const optionsToSave = getOptionsToSave(
+      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+    );
 
     try {
-      // disable unsaved warning
-      // editorContainer.disableUnsavedWarning(); commentout because disableUnsavedWarning doesn't exitst
+      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, markdown);
 
-      // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
-      logger.debug('success to save');
+      // update pageData
+      updatePageData();
 
-      pageContainer.showSuccessToastr();
+      // set updated data
+      setRemoteRevisionId(res.revision._id);
+      mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
+      mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
+      updatePageTagsForEditors(res.tags);
 
-      updatePageTagsForEditors(tags);
+      // call reset
+      setIsInitialized(false);
+
+      logger.debug('success to save');
+
+      toastSuccess(t('successfully_saved_the_page'));
     }
     catch (error) {
       logger.error('failed to save', error);
-      pageContainer.showErrorToastr(error);
+      toastError(error);
     }
-  }, [editorMode, grant, isSlackEnabled, pageContainer, pageTags, slackChannels, updatePageTagsForEditors]);
+  }, [
+    grant, isSlackEnabled, pageTags, slackChannels, updatePageTagsForEditors, pageId, currentPagePath, currentPathname,
+    revisionIdHackmdSynced, updatePageData, mutateHasDraftOnHackmd, mutateRevisionIdHackmdSynced, t,
+  ]);
 
   /**
    * onChange event of HackmdEditor handler
    */
   const hackmdEditorChangeHandler = useCallback(async(body) => {
-    const hackmdUri = getHackmdUri();
 
-    if (hackmdUri == null) {
+    if (hackmdUri == null || pageId == null) {
       // do nothing
       return;
     }
 
-    // do nothing if contents are same
-    if (pageContainer.state.markdown === body) {
+    if (revision?.body === body) {
       return;
     }
 
-    // enable unsaved warning
-    // editorContainer.enableUnsavedWarning(); commentout because enableUnsavedWarning doesn't exitst
-
-    const params = {
-      pageId: pageContainer.state.pageId,
-    };
     try {
-      await apiPost('/hackmd.saveOnHackmd', params);
+      await apiPost('/hackmd.saveOnHackmd', { pageId });
     }
     catch (err) {
       logger.error(err);
     }
-  }, [getHackmdUri, pageContainer.state.markdown, pageContainer.state.pageId]);
+  }, [pageId, revision?.body, hackmdUri]);
 
   const penpalErrorOccuredHandler = useCallback((error) => {
-    pageContainer.showErrorToastr(error);
+    toastError(error);
 
     setHasError(true);
     setErrorMessage(t('hackmd.fail_to_connect'));
     setErrorReason(error.toString());
-  }, [pageContainer, t]);
+  }, [t]);
 
   const renderPreInitContent = useCallback(() => {
-    const hackmdUri = getHackmdUri();
-    const {
-      revisionId, revisionIdHackmdSynced, remoteRevisionId, pageId,
-    } = pageContainer.state;
     const isPageNotFound = pageId == null;
 
     let content;
@@ -316,7 +344,7 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
      * Start to edit
      */
     else {
-      const isRevisionOutdated = revisionId !== remoteRevisionId;
+      const isRevisionOutdated = revision?._id !== remoteRevisionId;
 
       content = (
         <div>
@@ -342,30 +370,25 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
         {content}
       </div>
     );
-  }, [discardChanges, getHackmdUri, isInitializing, isResume, pageContainer.state, resumeToEdit, startToEdit, t]);
+  }, [discardChanges, isInitializing, isResume, resumeToEdit, startToEdit, t, hackmdUri, pageId, remoteRevisionId, revisionIdHackmdSynced, revision?._id]);
 
-  if (editorMode == null) {
-    return null;
+  if (editorMode == null || revision == null) {
+    return <></>;
   }
 
-  const hackmdUri = getHackmdUri();
-  const {
-    markdown, pageIdOnHackmd,
-  } = pageContainer.state;
-
   let content;
 
   // TODO: typescriptize
   // using any because ref cann't used between FC and class conponent with type safe
   const AnyEditor = HackmdEditor as any;
 
-  if (isInitialized) {
+  if (isInitialized && hackmdUri != null) {
     content = (
       <AnyEditor
         ref={hackmdEditorRef}
         hackmdUri={hackmdUri}
         pageIdOnHackmd={pageIdOnHackmd}
-        initializationMarkdown={isResume() ? null : markdown}
+        initializationMarkdown={isResume() ? null : revision.body}
         onChange={hackmdEditorChangeHandler}
         onSaveWithShortcut={(document) => {
           onSaveWithShortcut(document);
@@ -403,7 +426,3 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
   );
 
 };
-
-const PageEditorByHackmdWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer]);
-
-export default PageEditorByHackmdWrapper;

+ 22 - 1
packages/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -1,5 +1,8 @@
+import { useEffect, useState } from 'react';
+
 import EventEmitter from 'events';
 
+import { useRouter } from 'next/router';
 import { Element } from 'react-markdown/lib/rehype-filter';
 
 import { NextLink } from './NextLink';
@@ -48,10 +51,28 @@ export const Header = (props: HeaderProps): JSX.Element => {
     node, id, children, level,
   } = props;
 
+  const router = useRouter();
+
+  const [isActive, setActive] = useState(false);
+
   const CustomTag = `h${level}` as keyof JSX.IntrinsicElements;
 
+  // update isActive when hash is changed
+  useEffect(() => {
+    const handler = (url: string) => {
+      const hash = (new URL(url, 'https://example.com')).hash.slice(1);
+      setActive(hash === id);
+    };
+
+    router.events.on('hashChangeComplete', handler);
+
+    return () => {
+      router.events.off('hashChangeComplete', handler);
+    };
+  }, [id, router.events]);
+
   return (
-    <CustomTag id={id} className={`revision-head ${styles['revision-head']}`}>
+    <CustomTag id={id} className={`revision-head ${styles['revision-head']} ${isActive ? 'blink' : ''}`}>
       {children}
       <NextLink href={`#${id}`} className="revision-head-link">
         <span className="icon-link"></span>

+ 14 - 2
packages/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -1,4 +1,5 @@
 import Link, { LinkProps } from 'next/link';
+import { Link as ScrollLink } from 'react-scroll';
 
 import { useSiteUrl } from '~/stores/context';
 
@@ -25,9 +26,20 @@ export const NextLink = ({
 
   const { data: siteUrl } = useSiteUrl();
 
+  if (href == null) {
+    return <a className={className}>{children}</a>;
+  }
+
   // when href is an anchor link
-  if (href == null || isAnchorLink(href)) {
-    return <a href={href} className={className}>{children}</a>;
+  if (isAnchorLink(href)) {
+    const to = href.slice(1);
+    return (
+      <Link href={href} scroll={false}>
+        <ScrollLink href={href} to={to} className={className} smooth="easeOutQuart" offset={-100} duration={800}>
+          {children}
+        </ScrollLink>
+      </Link>
+    );
   }
 
   if (isExternalLink(href, siteUrl)) {

+ 2 - 5
packages/app/src/components/SavePageControls.tsx

@@ -59,7 +59,7 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
   }, [mutateIsEnabledUnsavedWarning]);
 
 
-  if (isEditable == null || isAclEnabled == null) {
+  if (isEditable == null || isAclEnabled == null || grantData == null) {
     return null;
   }
 
@@ -67,10 +67,7 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
     return null;
   }
 
-  const grant = grantData?.grant || PageGrant.GRANT_PUBLIC;
-  const grantedGroup = grantData?.grantedGroup;
-
-  // const {  pageContainer } = props;
+  const { grant, grantedGroup } = grantData;
 
   const isRootPage = isTopPage(currentPagePath ?? '');
   const labelSubmitButton = pageId == null ? t('Create') : t('Update');

+ 2 - 1
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -123,7 +123,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
-  const { data: rendererOptions } = useSearchResultOptions();
+  const { data: rendererOptions } = useSearchResultOptions(pageWithMeta.data.path, highlightKeywords);
   const { data: currentUser } = useCurrentUser();
 
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
@@ -217,6 +217,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           highlightKeywords={highlightKeywords}
         />
         <PageComment
+          rendererOptions={rendererOptions}
           pageId={page._id}
           revision={page.revision}
           currentUser={currentUser}

+ 4 - 1
packages/app/src/components/TableOfContents.module.scss

@@ -9,6 +9,10 @@
   border-bottom: 1px solid transparent;
 
   .revision-toc-content {
+    ul {
+      list-style-type: disc;
+    }
+
     li {
       margin: 6px;
     }
@@ -22,7 +26,6 @@
     // first level of li
     > ul > li {
       padding: 5px;
-      margin-right: 4px;
       margin-left: 17px;
     }
   }

+ 2 - 11
packages/app/src/components/TableOfContents.tsx

@@ -1,9 +1,7 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback } from 'react';
 
 import ReactMarkdown from 'react-markdown';
 
-import { blinkElem } from '~/client/util/blink-section-header';
-import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
 import { useIsUserPage } from '~/stores/context';
 import { useTocOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
@@ -22,7 +20,7 @@ const TableOfContents = (): JSX.Element => {
 
   const { data: isUserPage } = useIsUserPage();
 
-  const [tocHtml, setTocHtml] = useState('');
+  // const [tocHtml, setTocHtml] = useState('');
 
   const { data: rendererOptions } = useTocOptions();
 
@@ -49,13 +47,6 @@ const TableOfContents = (): JSX.Element => {
     return bottom - (containerTop + containerPaddingTop);
   }, [isUserPage]);
 
-  useEffect(() => {
-    const tocDom = document.getElementById('revision-toc-content');
-    if (tocDom == null) { return }
-    const anchorsInToc = Array.from(tocDom.getElementsByTagName('a'));
-    addSmoothScrollEvent(anchorsInToc, blinkElem);
-  }, [tocHtml]);
-
   return (
     <div id="revision-toc" className={`revision-toc ${styles['revision-toc']}`}>
       <StickyStretchableScroller

+ 1 - 1
packages/app/src/components/Theme/ThemeDefault.global.scss

@@ -116,7 +116,7 @@
 
 //== Dark Mode
 //
-:root[data-theme='light'] .theme-dark {
+:root[data-theme='dark'] .theme-default {
   $primary: #115cd3;
   $accent: #db00c2;
 

+ 2 - 4
packages/app/src/interfaces/attachment.ts

@@ -1,10 +1,8 @@
-import type { IAttachment } from '@growi/core';
+import type { HasObjectId, IAttachment } from '@growi/core';
 
 import type { PaginateResult } from './mongoose-utils';
 
 
 export type IResAttachmentList = {
-  data: {
-    paginateResult: PaginateResult<IAttachment>
-  }
+  paginateResult: PaginateResult<IAttachment & HasObjectId>
 };

+ 1 - 0
packages/app/src/interfaces/editor-settings.ts

@@ -43,4 +43,5 @@ export type OptionsToSave = {
   pageTags: string[] | null;
   grantUserGroupId?: string | null;
   grantUserGroupName?: string | null;
+  isSyncRevisionToHackmd?: boolean;
 };

+ 26 - 6
packages/app/src/pages/login.page.tsx

@@ -4,9 +4,10 @@ import React from 'react';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
-import dynamic from 'next/dynamic';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
+import { LoginForm } from '~/components/LoginForm';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 
 import {
@@ -16,11 +17,9 @@ import {
 
 
 import {
-  CommonProps, getServerSideCommonProps, useCustomTitle,
+  CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
 } from './utils/commons';
 
-const LoginForm = dynamic(() => import('~/components/LoginForm'), { ssr: false });
-
 type Props = CommonProps & {
 
   pageWithMetaStr: string,
@@ -41,12 +40,32 @@ const LoginPage: NextPage<Props> = (props: Props) => {
 
   return (
     <NoLoginLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
-      <LoginForm objOfIsExternalAuthEnableds={props.enabledStrategies} isLocalStrategySetup={true} isLdapStrategySetup={true}
-        isRegistrationEnabled={true} registrationWhiteList={props.registrationWhiteList} isPasswordResetEnabled={true} />
+      <LoginForm
+        // Todo: These props should be set properly. https://redmine.weseek.co.jp/issues/104847
+        objOfIsExternalAuthEnableds={props.enabledStrategies}
+        isLocalStrategySetup={true}
+        isLdapStrategySetup={true}
+        isEmailAuthenticationEnabled={false}
+        isRegistrationEnabled={true}
+        registrationWhiteList={props.registrationWhiteList}
+        isPasswordResetEnabled={true}
+        isMailerSetup={false}
+      />
     </NoLoginLayout>
   );
 };
 
+/**
+ * for Server Side Translations
+ * @param context
+ * @param props
+ * @param namespacesRequired
+ */
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
 function injectEnabledStrategies(context: GetServerSidePropsContext, props: Props): void {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
@@ -92,6 +111,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   injectServerConfigurations(context, props);
   injectEnabledStrategies(context, props);
+  await injectNextI18NextConfigurations(context, props, ['translation']);
 
   return {
     props,

+ 7 - 0
packages/app/src/server/crowi/express-init.js

@@ -170,4 +170,11 @@ module.exports = function(crowi, app) {
   app.use(middlewares.swigFunctions());
 
   // app.use(i18nMiddleware.handle(i18next));
+  // TODO: Remove this workaround implementation when i18n works correctly.
+  //       For now, req.t returns string given to req.t(string)
+  app.use((req, res, next) => {
+    req.t = str => (typeof str === 'string' ? str : '');
+
+    next();
+  });
 };

+ 2 - 2
packages/app/src/server/middlewares/register-form-validator.ts

@@ -13,9 +13,9 @@ export const registerRules = () => {
     body('registerForm.name').not().isEmpty().withMessage('Name field is required'),
     body('registerForm.email')
       .isEmail()
-      .withMessage('Email format is invalid.')
+      .withMessage('Email format is invalid')
       .exists()
-      .withMessage('Email field is required.'),
+      .withMessage('Email field is required'),
     body('registerForm.password')
       .matches(/^[\x20-\x7F]*$/)
       .withMessage('Password has invalid character')

+ 9 - 1
packages/app/src/server/routes/apiv3/index.js

@@ -1,6 +1,8 @@
 import loggerFactory from '~/utils/logger';
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inject-user-registration-order-by-token-middleware';
+import * as registerFormValidator from '../../middlewares/register-form-validator';
 
 import pageListing from './page-listing';
 import * as userActivation from './user-activation';
@@ -13,7 +15,7 @@ const router = express.Router();
 const routerForAdmin = express.Router();
 const routerForAuth = express.Router();
 
-module.exports = (crowi) => {
+module.exports = (crowi, app) => {
 
   // add custom functions to express response
   require('./response')(express, crowi);
@@ -38,8 +40,14 @@ module.exports = (crowi) => {
   routerForAdmin.use('/activity', require('./activity')(crowi));
 
   // auth
+  const applicationInstalled = require('../../middlewares/application-installed')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+  const login = require('../login')(crowi, app);
+
   routerForAuth.use('/logout', require('./logout')(crowi));
 
+  routerForAuth.post('/register',
+    applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, addActivity, login.register);
 
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
 

+ 2 - 3
packages/app/src/server/routes/index.js

@@ -61,9 +61,9 @@ module.exports = function(crowi, app) {
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
-  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi);
+  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi, app);
 
-  app.use('/api-docs', require('./apiv3/docs')(crowi));
+  app.use('/api-docs', require('./apiv3/docs')(crowi, app));
 
   // Rate limiter
   app.use(rateLimiter);
@@ -84,7 +84,6 @@ module.exports = function(crowi, app) {
   app.post('/invited/activateInvited' , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrfProtection, login.invited);
   app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
-  app.post('/register'                , applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrfProtection, addActivity, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
 
   app.get('/admin/*'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);

+ 73 - 85
packages/app/src/server/routes/login.js

@@ -19,22 +19,15 @@ module.exports = function(crowi, app) {
     req.login(userData, (err) => {
       if (err) {
         logger.debug(err);
-        // I created a flash message in case the user information that processing was successful is not stored in the session.
-        req.flash('successMessage', req.t('message.successfully_created', { username: userData.username }));
       }
       else {
         // update lastLoginAt
-        userData.updateLastLoginAt(new Date(), (err, userData) => {
+        userData.updateLastLoginAt(new Date(), (err) => {
           if (err) {
             logger.error(`updateLastLoginAt dumps error: ${err}`);
           }
         });
       }
-      // RegisterFormValidator.registerRule had code to guarantee that there was a password,
-      // but login.register did not. so I wrote this code.
-      if (!userData.password) {
-        return res.redirect('/me#password');
-      }
 
       const { redirectTo } = req.session;
       // remove session.redirectTo
@@ -43,10 +36,36 @@ module.exports = function(crowi, app) {
       const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
       activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      return res.safeRedirect(redirectTo);
+      return res.apiv3({ redirectTo });
     });
   };
 
+  async function sendEmailToAllAdmins(userData) {
+    // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
+    const admins = await User.findAdmins();
+
+    const appTitle = appService.getAppTitle();
+
+    const promises = admins.map((admin) => {
+      return mailService.send({
+        to: admin.email,
+        subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
+        template: path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt'),
+        vars: {
+          createdUser: userData,
+          admin,
+          url: appService.getSiteUrl(),
+          appTitle,
+        },
+      });
+    });
+
+    const results = await Promise.allSettled(promises);
+    results
+      .filter(result => result.status === 'rejected')
+      .forEach(result => logger.error(result.reason));
+  }
+
   actions.error = function(req, res) {
     const reason = req.params.reason;
 
@@ -93,97 +112,66 @@ module.exports = function(crowi, app) {
 
   actions.register = function(req, res) {
     if (req.user != null) {
-      return res.redirect('/');
+      return res.apiv3Err('user_already_logged_in', 403);
     }
 
     // config で closed ならさよなら
     if (configManager.getConfig('crowi', 'security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED) {
-      return res.redirect('/');
+      return res.apiv3Err('registration_closed', 403);
     }
 
-    if (req.method === 'POST' && req.form.isValid) {
-      const registerForm = req.form.registerForm || {};
-
-      const name = registerForm.name;
-      const username = registerForm.username;
-      const email = registerForm.email;
-      const password = registerForm.password;
-
-      // email と username の unique チェックする
-      User.isRegisterable(email, username, (isRegisterable, errOn) => {
-        let isError = false;
-        if (!User.isEmailValid(email)) {
-          isError = true;
-          req.flash('registerWarningMessage', req.t('message.email_address_could_not_be_used'));
-        }
-        if (!isRegisterable) {
-          if (!errOn.username) {
-            isError = true;
-            req.flash('registerWarningMessage', req.t('message.user_id_is_not_available'));
-          }
-          if (!errOn.email) {
-            isError = true;
-            req.flash('registerWarningMessage', req.t('message.email_address_is_already_registered'));
-          }
+    if (!req.form.isValid) {
+      const errors = req.form.errors;
+      return res.apiv3Err(errors, 401);
+    }
+
+    const registerForm = req.form.registerForm || {};
+
+    const name = registerForm.name;
+    const username = registerForm.username;
+    const email = registerForm.email;
+    const password = registerForm.password;
+
+    // email と username の unique チェックする
+    User.isRegisterable(email, username, (isRegisterable, errOn) => {
+      const errors = [];
+      if (!User.isEmailValid(email)) {
+        errors.push('email_address_could_not_be_used');
+      }
+      if (!isRegisterable) {
+        if (!errOn.username) {
+          errors.push('user_id_is_not_available');
         }
-        if (isError) {
-          debug('isError user register error', errOn);
-          return res.redirect('/register');
+        if (!errOn.email) {
+          errors.push('email_address_is_already_registered');
         }
+      }
+      if (errors.length > 0) {
+        debug('isError user register error', errOn);
+        return res.apiv3Err(errors, 400);
+      }
 
-        User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
-          if (err) {
-            if (err.name === 'UserUpperLimitException') {
-              req.flash('registerWarningMessage', req.t('message.can_not_register_maximum_number_of_users'));
-            }
-            else {
-              req.flash('registerWarningMessage', req.t('message.failed_to_register'));
-            }
-            return res.redirect('/register');
+      User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
+        if (err) {
+          const errors = [];
+          if (err.name === 'UserUpperLimitException') {
+            errors.push('can_not_register_maximum_number_of_users');
           }
-
-          if (configManager.getConfig('crowi', 'security:registrationMode') !== aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-            // send mail asynchronous
-            sendEmailToAllAdmins(userData);
+          else {
+            errors.push('failed_to_register');
           }
+          return res.apiv3Err(errors, 405);
+        }
 
+        if (configManager.getConfig('crowi', 'security:registrationMode') !== aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+          // send mail asynchronous
+          sendEmailToAllAdmins(userData);
+        }
 
-          return registerSuccessHandler(req, res, userData);
-        });
-      });
-    }
-    else { // method GET of form is not valid
-      debug('session is', req.session);
-      const isRegistering = true;
-      return res.render('login', { isRegistering });
-    }
-  };
-
-  async function sendEmailToAllAdmins(userData) {
-    // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
-    const admins = await User.findAdmins();
-
-    const appTitle = appService.getAppTitle();
-
-    const promises = admins.map((admin) => {
-      return mailService.send({
-        to: admin.email,
-        subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
-        template: path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt'),
-        vars: {
-          createdUser: userData,
-          admin,
-          url: appService.getSiteUrl(),
-          appTitle,
-        },
+        return registerSuccessHandler(req, res, userData);
       });
     });
-
-    const results = await Promise.allSettled(promises);
-    results
-      .filter(result => result.status === 'rejected')
-      .forEach(result => logger.error(result.reason));
-  }
+  };
 
   actions.invited = async function(req, res) {
     if (!req.user) {

+ 0 - 1
packages/app/src/server/service/app.ts

@@ -114,7 +114,6 @@ export default class AppService implements S2sMessageHandlable {
   }
 
   async setupAfterInstall(): Promise<void> {
-    await this.crowi.pluginService.autoDetectAndLoadPlugins();
     this.crowi.setupRoutesAtLast();
     this.crowi.setupGlobalErrorHandlers();
   }

+ 1 - 1
packages/app/src/server/service/installer.ts

@@ -144,7 +144,7 @@ export class InstallerService {
     const rootRevision = await Revision.findOne({ path: '/' });
     rootPage.creator = adminUser._id;
     rootPage.lastUpdateUser = adminUser._id;
-    rootRevision.creator = adminUser._id;
+    rootRevision.author = adminUser._id;
     await Promise.all([rootPage.save(), rootRevision.save()]);
 
     // create initial pages

+ 90 - 0
packages/app/src/services/renderer/rehype-plugins/keyword-highlighter.ts

@@ -0,0 +1,90 @@
+import { Root, Element, Text } from 'hast';
+import rehypeRewrite from 'rehype-rewrite';
+import { Plugin } from 'unified';
+
+
+/**
+ * This method returns ['foo', 'bar', 'foo']
+ *  when the arguments are { keyword: 'foo', value: 'foobarfoo' }
+ * @param keyword
+ * @param value
+ * @returns
+ */
+function splitWithKeyword(keyword: string, value: string): string[] {
+  if (value.length === 0) {
+    return [];
+  }
+
+  let cursorStart = 0;
+  let cursorEnd = 0;
+
+  const splitted: string[] = [];
+
+  do {
+    cursorEnd = value.indexOf(keyword, cursorStart);
+
+    // not found
+    if (cursorEnd === -1) {
+      cursorEnd = value.length;
+    }
+    // keyword is found
+    else if (cursorEnd === cursorStart) {
+      cursorEnd += keyword.length;
+    }
+
+    splitted.push(value.slice(cursorStart, cursorEnd));
+    cursorStart = cursorEnd;
+  } while (cursorStart < value.length);
+
+  return splitted;
+}
+
+function wrapWithEm(textElement: Text): Element {
+  return {
+    type: 'element',
+    tagName: 'em',
+    properties: {
+      className: 'highlighted-keyword',
+    },
+    children: [textElement],
+  };
+}
+
+function highlight(keyword: string, node: Text, index: number, parent: Root | Element): void {
+  if (node.value.includes(keyword)) {
+    const splitted = splitWithKeyword(keyword, node.value);
+
+    parent.children[index] = {
+      type: 'element',
+      tagName: 'span',
+      properties: {},
+      children: splitted.map((text) => {
+        return text === keyword
+          ? wrapWithEm({ type: 'text', value: keyword })
+          : { type: 'text', value: text };
+      }),
+    };
+  }
+}
+
+
+export type KeywordHighlighterPluginParams = {
+  keywords?: string | string[],
+}
+
+export const rehypePlugin: Plugin<[KeywordHighlighterPluginParams]> = (options) => {
+  if (options?.keywords == null) {
+    return node => node;
+  }
+
+  const keywords = (typeof options.keywords === 'string') ? [options.keywords] : options.keywords;
+
+  // return rehype-rewrite with hithlighter
+  return rehypeRewrite.bind(this)({
+    rewrite: (node, index, parent) => {
+      if (parent != null && index != null && node.type === 'text') {
+        keywords.forEach(keyword => highlight(keyword, node, index, parent));
+      }
+    },
+  });
+};

+ 54 - 0
packages/app/src/services/renderer/rehype-plugins/relocate-toc.ts

@@ -0,0 +1,54 @@
+import rehypeToc, { HtmlElementNode } from 'rehype-toc';
+import { Plugin } from 'unified';
+import { Node } from 'unist';
+
+type StoreTocPluginParams = {
+  storeTocNode: (toc: HtmlElementNode) => void,
+}
+
+export const rehypePluginStore: Plugin<[StoreTocPluginParams]> = (options) => {
+  return rehypeToc.bind(this)({
+    nav: false,
+    headings: ['h1', 'h2', 'h3'],
+    customizeTOC: (toc: HtmlElementNode) => {
+      // For storing tocNode to global state with swr
+      // search: tocRef.current
+      options.storeTocNode(toc);
+
+      return false; // not show toc in body
+    },
+  });
+};
+
+
+// method for replace <ol> to <ul>
+const replaceOlToUl = (children: Node[]) => {
+  children.forEach((child) => {
+    if (child.type === 'element' && child.tagName === 'ol') {
+      child.tagName = 'ul';
+    }
+    if (child.children != null) {
+      replaceOlToUl(child.children as Node[]);
+    }
+  });
+};
+
+type RestoreTocPluginParams = {
+  tocNode?: HtmlElementNode,
+}
+
+export const rehypePluginRestore: Plugin<[RestoreTocPluginParams]> = (options) => {
+  const { tocNode } = options;
+
+  return rehypeToc.bind(this)({
+    headings: ['h1', 'h2', 'h3'],
+    customizeTOC: () => {
+      if (tocNode != null) {
+        replaceOlToUl([tocNode]); // replace <ol> to <ul>
+
+        // restore toc
+        return tocNode;
+      }
+    },
+  });
+};

+ 12 - 0
packages/app/src/services/renderer/remark-plugins/plantuml.ts

@@ -0,0 +1,12 @@
+import plantuml from '@akebifiky/remark-simple-plantuml';
+import { Plugin } from 'unified';
+
+type PlantUMLPluginParams = {
+  baseUrl?: string,
+}
+
+export const remarkPlugin: Plugin<[PlantUMLPluginParams]> = (options) => {
+  const baseUrl = options.baseUrl ?? 'https://www.plantuml.com/plantuml/svg';
+
+  return plantuml.bind(this)({ baseUrl });
+};

+ 48 - 0
packages/app/src/services/renderer/remark-plugins/xsv-to-table.ts

@@ -0,0 +1,48 @@
+import csvToMarkdownTable from 'csv-to-markdown-table';
+import { fromMarkdown } from 'mdast-util-from-markdown';
+import { gfmTableFromMarkdown } from 'mdast-util-gfm-table';
+import { gfmTable } from 'micromark-extension-gfm-table';
+import { Plugin } from 'unified';
+import { Node } from 'unist';
+import { visit } from 'unist-util-visit';
+
+type Lang = 'csv' | 'csv-h' | 'tsv' | 'tsv-h';
+
+function isXsv(lang: unknown): lang is Lang {
+  return /^(csv|csv-h|tsv|tsv-h)$/.test(lang as string);
+}
+
+// workaround for the broken type definition of csv-to-markdown-table -- 2022.09.15 Yuki Takei
+const csvToMarkdown = csvToMarkdownTable.csvToMarkdown ?? csvToMarkdownTable;
+
+function rewriteNode(node: Node, lang: Lang) {
+  const tableContents = node.value as string;
+
+  const tableDoc = csvToMarkdown(
+    tableContents,
+    lang === 'csv' || lang === 'csv-h' ? ',' : '\t',
+    lang === 'csv-h' || lang === 'tsv-h',
+  );
+  const tableTree = fromMarkdown(tableDoc, {
+    extensions: [gfmTable],
+    mdastExtensions: [gfmTableFromMarkdown],
+  });
+
+  // replace node
+  if (tableTree.children[0] != null) {
+    node.type = 'table';
+    node.children = tableTree.children[0].children;
+  }
+}
+
+export const remarkPlugin: Plugin = function() {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (node.type === 'code') {
+        if (isXsv(node.lang)) {
+          rewriteNode(node, node.lang);
+        }
+      }
+    });
+  };
+};

+ 42 - 63
packages/app/src/services/renderer/renderer.tsx

@@ -12,7 +12,7 @@ import katex from 'rehype-katex';
 import raw from 'rehype-raw';
 import sanitize, { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
 import slug from 'rehype-slug';
-import toc, { HtmlElementNode } from 'rehype-toc';
+import { HtmlElementNode } from 'rehype-toc';
 import breaks from 'remark-breaks';
 import emoji from 'remark-emoji';
 import gfm from 'remark-gfm';
@@ -29,9 +29,13 @@ import loggerFactory from '~/utils/logger';
 
 import * as addClass from './rehype-plugins/add-class';
 import * as addLineNumberAttribute from './rehype-plugins/add-line-number-attribute';
+import * as keywordHighlighter from './rehype-plugins/keyword-highlighter';
 import { relativeLinks } from './rehype-plugins/relative-links';
 import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker';
+import * as toc from './rehype-plugins/relocate-toc';
+import * as plantuml from './remark-plugins/plantuml';
 import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
+import * as xsvToTable from './remark-plugins/xsv-to-table';
 
 // import CsvToTable from './PreProcessor/CsvToTable';
 // import EasyGrid from './PreProcessor/EasyGrid';
@@ -245,6 +249,7 @@ export type RendererOptions = Omit<ReactMarkdownOptions, 'remarkPlugins' | 'rehy
 const commonSanitizeOption: SanitizeOption = deepmerge(
   sanitizeDefaultSchema,
   {
+    clobberPrefix: 'mdcont-',
     attributes: {
       '*': ['class', 'className', 'style'],
     },
@@ -282,6 +287,7 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
   return {
     remarkPlugins: [
       gfm,
+      emoji,
       pukiwikiLikeLinker,
       growiPlugin,
     ],
@@ -312,8 +318,9 @@ export const generateViewOptions = (
 
   // add remark plugins
   remarkPlugins.push(
-    emoji,
     math,
+    [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
@@ -323,36 +330,13 @@ export const generateViewOptions = (
   // add rehype plugins
   rehypePlugins.push(
     slug,
-    [toc, {
-      nav: false,
-      headings: ['h1', 'h2', 'h3'],
-      customizeTOC: (toc: HtmlElementNode) => {
-        // method for replace <ol> to <ul>
-        const replacer = (children) => {
-          children.forEach((child) => {
-            if (child.type === 'element' && child.tagName === 'ol') {
-              child.tagName = 'ul';
-            }
-            if (child.children) {
-              replacer(child.children);
-            }
-          });
-        };
-        replacer([toc]); // replace <ol> to <ul>
-
-        // For storing tocNode to global state with swr
-        // search: tocRef.current
-        storeTocNode(toc);
-
-        return false; // not show toc in body
-      },
-    }],
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
     [sanitize, deepmerge(
       commonSanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
     katex,
+    [toc.rehypePluginStore, { storeTocNode }],
     // [autoLinkHeadings, {
     //   behavior: 'append',
     // }]
@@ -386,17 +370,14 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
 
   const options = generateCommonOptions(undefined, config);
 
-  const { remarkPlugins, rehypePlugins } = options;
+  const { rehypePlugins } = options;
 
   // add remark plugins
-  remarkPlugins.push(emoji);
+  // remarkPlugins.push();
 
   // add rehype plugins
   rehypePlugins.push(
-    [toc, {
-      headings: ['h1', 'h2', 'h3'],
-      customizeTOC: () => tocNode,
-    }],
+    [toc.rehypePluginRestore, { tocNode }],
     [sanitize, commonSanitizeOption],
   );
   // renderer.rehypePlugins.push([autoLinkHeadings, {
@@ -407,25 +388,16 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   return options;
 };
 
-export const generatePreviewOptions = (pagePath: string, config: RendererConfig): RendererOptions => {
-  // // Add configurers for preview
-  // renderer.addConfigurers([
-  //   new FooternoteConfigurer(),
-  //   new HeaderLineNumberConfigurer(),
-  //   new TableConfigurer(),
-  // ]);
-
-  // renderer.setMarkdownSettings({ breaks: rendererSettings?.isEnabledLinebreaks });
-  // renderer.configure();
-
+export const generateSimpleViewOptions = (config: RendererConfig, pagePath: string, highlightKeywords?: string | string[]): RendererOptions => {
   const options = generateCommonOptions(pagePath, config);
 
   const { remarkPlugins, rehypePlugins, components } = options;
 
   // add remark plugins
   remarkPlugins.push(
-    emoji,
     math,
+    [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
@@ -435,16 +407,12 @@ export const generatePreviewOptions = (pagePath: string, config: RendererConfig)
   // add rehype plugins
   rehypePlugins.push(
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
-    addLineNumberAttribute.rehypePlugin,
+    [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     [sanitize, deepmerge(
       commonSanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
-      addLineNumberAttribute.sanitizeOption,
     )],
     katex,
-    // [autoLinkHeadings, {
-    //   behavior: 'append',
-    // }]
   );
 
   // add components
@@ -456,29 +424,40 @@ export const generatePreviewOptions = (pagePath: string, config: RendererConfig)
   return options;
 };
 
-export const generateCommentPreviewOptions = (config: RendererConfig): RendererOptions => {
-  const options = generateCommonOptions(undefined, config);
-  const { remarkPlugins, rehypePlugins } = options;
+export const generatePreviewOptions = (config: RendererConfig, pagePath: string): RendererOptions => {
+  const options = generateCommonOptions(pagePath, config);
+
+  const { remarkPlugins, rehypePlugins, components } = options;
 
   // add remark plugins
-  remarkPlugins.push(emoji);
-  if (config.isEnabledLinebreaksInComments) {
+  remarkPlugins.push(
+    math,
+    [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    xsvToTable.remarkPlugin,
+    lsxGrowiPlugin.remarkPlugin,
+  );
+  if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
   }
 
-  // renderer.addConfigurers([
-  //   new TableConfigurer(),
-  // ]);
-
-  // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaksInComments });
-  // renderer.configure();
-
   // add rehype plugins
   rehypePlugins.push(
-    [sanitize, commonSanitizeOption],
+    [lsxGrowiPlugin.rehypePlugin, { pagePath }],
+    addLineNumberAttribute.rehypePlugin,
+    [sanitize, deepmerge(
+      commonSanitizeOption,
+      lsxGrowiPlugin.sanitizeOption,
+      addLineNumberAttribute.sanitizeOption,
+    )],
+    katex,
   );
 
-  verifySanitizePlugin(options);
+  // add components
+  if (components != null) {
+    components.lsx = props => <Lsx {...props} />;
+  }
+
+  verifySanitizePlugin(options, false);
   return options;
 };
 

+ 13 - 9
packages/app/src/stores/attachment.tsx

@@ -1,11 +1,13 @@
 import { useCallback } from 'react';
 
 import {
+  HasObjectId,
   IAttachment, Nullable, SWRResponseWithUtils, withUtils,
 } from '@growi/core';
 import useSWR from 'swr';
 
-import { apiGet, apiPost } from '~/client/util/apiv1-client';
+import { apiPost } from '~/client/util/apiv1-client';
+import { apiv3Get } from '~/client/util/apiv3-client';
 import { IResAttachmentList } from '~/interfaces/attachment';
 
 type Util = {
@@ -13,7 +15,7 @@ type Util = {
 };
 
 type IDataAttachmentList = {
-  attachments: IAttachment[]
+  attachments: (IAttachment & HasObjectId)[]
   totalAttachments: number
   limit: number
 };
@@ -21,17 +23,19 @@ type IDataAttachmentList = {
 export const useSWRxAttachments = (pageId?: Nullable<string>, pageNumber?: number): SWRResponseWithUtils<Util, IDataAttachmentList, Error> => {
   const shouldFetch = pageId != null && pageNumber != null;
 
-  const fetcher = useCallback(async(endpoint) => {
-    const res = await apiGet<IResAttachmentList>(endpoint, { pageId, pageNumber });
+  const fetcher = useCallback(async(endpoint, pageId, pageNumber) => {
+    const res = await apiv3Get<IResAttachmentList>(endpoint, { pageId, pageNumber });
+    const resAttachmentList = res.data;
+    const { paginateResult } = resAttachmentList;
     return {
-      attachments: res.data.paginateResult.docs,
-      totalAttachments: res.data.paginateResult.totalDocs,
-      limit: res.data.paginateResult.limit,
+      attachments: paginateResult.docs,
+      totalAttachments: paginateResult.totalDocs,
+      limit: paginateResult.limit,
     };
-  }, [pageId, pageNumber]);
+  }, []);
 
   const swrResponse = useSWR(
-    shouldFetch ? ['/attachments/list', pageId, pageNumber] : null,
+    shouldFetch ? ['/attachment/list', pageId, pageNumber] : null,
     fetcher,
   );
 

+ 37 - 10
packages/app/src/stores/renderer.tsx

@@ -5,7 +5,7 @@ import useSWRImmutable from 'swr/immutable';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import {
   RendererOptions,
-  generatePreviewOptions, generateCommentPreviewOptions, generateOthersOptions,
+  generateSimpleViewOptions, generatePreviewOptions, generateOthersOptions,
   generateViewOptions, generateTocOptions,
 } from '~/services/renderer/renderer';
 
@@ -83,29 +83,56 @@ export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
   const isAllDataValid = currentPagePath != null && rendererConfig != null;
 
   const key = isAllDataValid
-    ? ['previewOptions', currentPagePath, rendererConfig]
+    ? ['previewOptions', rendererConfig, currentPagePath]
     : null;
 
   return useSWRImmutable<RendererOptions, Error>(
     key,
-    (rendererId, currentPagePath, rendererConfig) => generatePreviewOptions(currentPagePath, rendererConfig),
+    (rendererId, rendererConfig, currentPagePath) => generatePreviewOptions(rendererConfig, currentPagePath),
     {
-      fallbackData: isAllDataValid ? generatePreviewOptions(currentPagePath, rendererConfig) : undefined,
+      fallbackData: isAllDataValid ? generatePreviewOptions(rendererConfig, currentPagePath) : undefined,
     },
   );
 };
 
-export const useCommentPreviewOptions = (): SWRResponse<RendererOptions, Error> => {
-  const key = 'commentPreviewOptions';
+export const useCommentForCurrentPageOptions = (): SWRResponse<RendererOptions, Error> => {
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: rendererConfig } = useRendererConfig();
+
+  const isAllDataValid = currentPagePath != null && rendererConfig != null;
+
+  const key = isAllDataValid
+    ? ['commentForCurrentPageOptions', rendererConfig, currentPagePath]
+    : null;
 
-  return _useOptionsBase(key, generateCommentPreviewOptions);
+  return useSWRImmutable<RendererOptions, Error>(
+    key,
+    (rendererId, rendererConfig, currentPagePath) => generateSimpleViewOptions(rendererConfig, currentPagePath),
+    {
+      fallbackData: isAllDataValid ? generateSimpleViewOptions(rendererConfig, currentPagePath) : undefined,
+    },
+  );
 };
+export const useCommentPreviewOptions = useCommentForCurrentPageOptions;
 
-export const useSearchResultOptions = (): SWRResponse<RendererOptions, Error> => {
-  const key = 'searchResultOptions';
+export const useSelectedPagePreviewOptions = (pagePath: string, highlightKeywords?: string | string[]): SWRResponse<RendererOptions, Error> => {
+  const { data: rendererConfig } = useRendererConfig();
 
-  return _useOptionsBase(key, generateOthersOptions);
+  const isAllDataValid = rendererConfig != null;
+
+  const key = isAllDataValid
+    ? ['selectedPagePreviewOptions', rendererConfig, pagePath, highlightKeywords]
+    : null;
+
+  return useSWRImmutable<RendererOptions, Error>(
+    key,
+    (rendererId, rendererConfig, pagePath, highlightKeywords) => generateSimpleViewOptions(rendererConfig, pagePath, highlightKeywords),
+    {
+      fallbackData: isAllDataValid ? generateSimpleViewOptions(rendererConfig, pagePath, highlightKeywords) : undefined,
+    },
+  );
 };
+export const useSearchResultOptions = useSelectedPagePreviewOptions;
 
 export const useTimelineOptions = (): SWRResponse<RendererOptions, Error> => {
   const key = 'timelineOptions';

+ 6 - 7
packages/app/src/stores/ui.tsx

@@ -1,7 +1,7 @@
 import { RefObject } from 'react';
 
 import {
-  isClient, isServer, pagePathUtils, Nullable,
+  isClient, isServer, pagePathUtils, Nullable, PageGrant,
 } from '@growi/core';
 import { withUtils, SWRResponseWithUtils } from '@growi/core/src/utils/with-utils';
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
@@ -27,7 +27,7 @@ import {
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
 
-const { isTrashTopPage } = pagePathUtils;
+const { isTrashTopPage, isUsersTopPage } = pagePathUtils;
 
 const logger = loggerFactory('growi:stores:ui');
 
@@ -367,7 +367,7 @@ export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<bool
 };
 
 export const useSelectedGrant = (initialData?: Nullable<IPageGrantData>): SWRResponse<Nullable<IPageGrantData>, Error> => {
-  return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData);
+  return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } });
 };
 
 export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {
@@ -424,22 +424,21 @@ export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> =>
 export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowTagLabel';
   const { data: pageId } = useCurrentPageId();
-  const { data: isUserPage } = useIsUserPage();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isIdenticalPath } = useIsIdenticalPath();
   const { data: isNotFound } = useIsNotFound();
   const { data: editorMode } = useEditorMode();
   const { data: shareLinkId } = useShareLinkId();
 
-  const includesUndefined = [isUserPage, currentPagePath, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined);
+  const includesUndefined = [currentPagePath, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined);
 
   const isViewMode = editorMode === EditorMode.View;
 
   return useSWRImmutable(
-    includesUndefined ? null : [key, editorMode, pageId],
+    includesUndefined ? null : [key, pageId, currentPagePath, isIdenticalPath, isNotFound, editorMode, shareLinkId],
     // "/trash" page does not exist on page collection and unable to add tags
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    () => !isUserPage && !isTrashTopPage(currentPagePath!) && shareLinkId == null && !isIdenticalPath && !(isViewMode && isNotFound),
+    () => !isUsersTopPage(currentPagePath!) && !isTrashTopPage(currentPagePath!) && shareLinkId == null && !isIdenticalPath && !(isViewMode && isNotFound),
   );
 };
 

+ 9 - 0
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -245,6 +245,15 @@ ul.pagination {
   }
 }
 
+/*
+ * GROWI ToC
+ */
+.revision-toc-content {
+  ::marker {
+    color: lighten($bgcolor-global, 30%);
+  }
+}
+
 /*
  * GROWI subnavigation
  */

+ 9 - 0
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -242,6 +242,15 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
   }
 }
 
+/*
+ * GROWI ToC
+ */
+.revision-toc-content {
+  ::marker {
+    color: darken($bgcolor-global, 20%);
+  }
+}
+
 /*
  * GROWI on-edit
  */

+ 2 - 1
packages/plugin-lsx/package.json

@@ -29,7 +29,8 @@
   },
   "dependencies": {
     "@growi/core": "^5.1.5-RC.0",
-    "@growi/remark-growi-plugin": "^5.1.5-RC.0"
+    "@growi/remark-growi-plugin": "^5.1.5-RC.0",
+    "@growi/ui": "^5.1.5-RC.0"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

+ 3 - 1
packages/ui/src/components/Attachment/Attachment.jsx

@@ -60,7 +60,9 @@ export class Attachment extends React.Component {
         <span className="mr-1 attachment-userpicture">
           <UserPicture user={attachment.creator} size="sm"></UserPicture>
         </span>
-        <a className="mr-2" href={attachment.filePathProxied}><i className={formatIcon}></i> {attachment.originalName}</a>
+        <a className="mr-2" href={attachment.filePathProxied} target="_blank" rel="noopener noreferrer">
+          <i className={formatIcon}></i> {attachment.originalName}
+        </a>
         <span className="mr-2">{fileType}</span>
         <span className="mr-2">{fileInUse}</span>
         <span className="mr-2">{btnDownload}</span>

+ 58 - 7
yarn.lock

@@ -2,6 +2,14 @@
 # yarn lockfile v1
 
 
+"@akebifiky/remark-simple-plantuml@^1.0.2":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@akebifiky/remark-simple-plantuml/-/remark-simple-plantuml-1.0.2.tgz#c353e4be4e5338b0165f1d6060da4960c325bad5"
+  integrity sha512-y5rWgQvU+DMpLKx1KlXCsgUeqVooqQm1S3hePLF9iecZy6YhKRybznFdvAvoAoiV2GoGhObQDHnneAl93llIcg==
+  dependencies:
+    plantuml-encoder "^1.4.0"
+    unist-util-visit "^2.0.2"
+
 "@alienfast/i18next-loader@^1.1.4":
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/@alienfast/i18next-loader/-/i18next-loader-1.1.4.tgz#213a6cd77222900a61b1635a212051193bcd5d1f"
@@ -7773,10 +7781,10 @@ csurf@^1.11.0:
     csrf "3.1.0"
     http-errors "~1.7.3"
 
-csv-to-markdown-table@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-1.0.1.tgz#43da1b0c0c483faa10a23921abc5e47a48e0daba"
-  integrity sha512-sw7oHNTBvmvztdDp5ZdIA3FPOy7fVol08hPgdSfVky4D1bcIoKwSiUeB/3G99mSaHnZh7wgCHcT7wAmyiyiaQA==
+csv-to-markdown-table@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-1.1.0.tgz#1c4546b4a6d7265d7715df51825c1852a7286247"
+  integrity sha512-gsnCustJ+9ckvdsivA8pRkBSUbr7vaMK5uuXU+gn5df93hUe2EqGPTazAJFGjc3vy0R9hjKHoLRjphTFy04bPg==
 
 currently-unhandled@^0.4.1:
   version "0.4.1"
@@ -11456,7 +11464,7 @@ hast-util-sanitize@^4.0.0:
   dependencies:
     "@types/hast" "^2.0.0"
 
-hast-util-select@^5.0.2:
+hast-util-select@^5.0.2, hast-util-select@~5.0.1:
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/hast-util-select/-/hast-util-select-5.0.2.tgz#8c603ebacf0f47e154c5fa2e5b7efc520813866b"
   integrity sha512-QGN5o7N8gq1BhUX96ApLE8izOXlf+IPkOVGXcp9Dskdd3w0OqZrn6faPAmS0/oVogwJOd0lWFSYmBK75e+030g==
@@ -17726,7 +17734,7 @@ pkg-dir@^5.0.0:
   dependencies:
     find-up "^5.0.0"
 
-plantuml-encoder@^1.2.5:
+plantuml-encoder@^1.2.5, plantuml-encoder@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/plantuml-encoder/-/plantuml-encoder-1.4.0.tgz#7899302cf785de956bf1a167e15420feee5975f7"
   integrity sha512-sxMwpDw/ySY1WB2CE3+IdMuEcWibJ72DDOsXLkSmEaSzwEUaYBT6DWgOfBiHGCux4q433X6+OEFWjlVqp7gL6g==
@@ -18586,6 +18594,14 @@ react-popper@^2.2.5:
     react-fast-compare "^3.0.1"
     warning "^4.0.2"
 
+react-scroll@^1.8.7:
+  version "1.8.7"
+  resolved "https://registry.yarnpkg.com/react-scroll/-/react-scroll-1.8.7.tgz#8020035329efad00f03964e18aff6822137de3aa"
+  integrity sha512-fBOIwweAlhicx8RqP9tQXn/Uhd+DTtVRjw+0VBsIn1Z+MjRYLhTZ0tMoTAU1vOD3dce8mI6copexI4yWII+Luw==
+  dependencies:
+    lodash.throttle "^4.1.1"
+    prop-types "^15.7.2"
+
 react-scrolllock@^1.0.9:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/react-scrolllock/-/react-scrolllock-1.0.9.tgz#7c9c3c0cce2ed55042af2808b6483b85b121cdcb"
@@ -19240,6 +19256,15 @@ rehype-raw@^6.1.1:
     hast-util-raw "^7.2.0"
     unified "^10.0.0"
 
+rehype-rewrite@^3.0.6:
+  version "3.0.6"
+  resolved "https://registry.yarnpkg.com/rehype-rewrite/-/rehype-rewrite-3.0.6.tgz#21e86982c7f2c169121bf10dd191f3768c6a6b29"
+  integrity sha512-REDTNCvsKcAazy8IQWzKp66AhSUDSOIKssSCqNqCcT9sN7JCwAAm3mWGTUdUzq80ABuy8d0D6RBwbnewu1aY1g==
+  dependencies:
+    hast-util-select "~5.0.1"
+    unified "~10.1.1"
+    unist-util-visit "~4.1.0"
+
 rehype-sanitize@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/rehype-sanitize/-/rehype-sanitize-5.0.1.tgz#dac01a7417bdd329260c74c74449697b4be5eb56"
@@ -23617,7 +23642,7 @@ unified-message-control@^4.0.0:
     vfile-location "^4.0.0"
     vfile-message "^3.0.0"
 
-unified@^10.0.0, unified@^10.1.0:
+unified@^10.0.0, unified@^10.1.0, unified@~10.1.1:
   version "10.1.2"
   resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df"
   integrity sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==
@@ -23836,6 +23861,14 @@ unist-util-visit-parents@^5.0.0:
     "@types/unist" "^2.0.0"
     unist-util-is "^5.0.0"
 
+unist-util-visit-parents@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-5.1.1.tgz#868f353e6fce6bf8fa875b251b0f4fec3be709bb"
+  integrity sha512-gks4baapT/kNRaWxuGkl5BIhoanZo7sC/cUT/JToSRNL1dYoXRFl75d++NkjYk4TAu2uv2Px+l8guMajogeuiw==
+  dependencies:
+    "@types/unist" "^2.0.0"
+    unist-util-is "^5.0.0"
+
 unist-util-visit@^1.1.0:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.1.tgz#4724aaa8486e6ee6e26d7ff3c8685960d560b1e3"
@@ -23843,6 +23876,15 @@ unist-util-visit@^1.1.0:
   dependencies:
     unist-util-visit-parents "^2.0.0"
 
+unist-util-visit@^2.0.2:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-2.0.3.tgz#c3703893146df47203bb8a9795af47d7b971208c"
+  integrity sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==
+  dependencies:
+    "@types/unist" "^2.0.0"
+    unist-util-is "^4.0.0"
+    unist-util-visit-parents "^3.0.0"
+
 unist-util-visit@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-3.1.0.tgz#9420d285e1aee938c7d9acbafc8e160186dbaf7b"
@@ -23861,6 +23903,15 @@ unist-util-visit@^4.0.0, unist-util-visit@^4.1.0:
     unist-util-is "^5.0.0"
     unist-util-visit-parents "^5.0.0"
 
+unist-util-visit@~4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-4.1.1.tgz#1c4842d70bd3df6cc545276f5164f933390a9aad"
+  integrity sha512-n9KN3WV9k4h1DxYR1LoajgN93wpEi/7ZplVe02IoB4gH5ctI1AaF2670BLHQYbwj+pY83gFtyeySFiyMHJklrg==
+  dependencies:
+    "@types/unist" "^2.0.0"
+    unist-util-is "^5.0.0"
+    unist-util-visit-parents "^5.1.1"
+
 universal-bunyan@^0.9.2:
   version "0.9.2"
   resolved "https://registry.yarnpkg.com/universal-bunyan/-/universal-bunyan-0.9.2.tgz#4cf09dc34070390d8f5df4fe9af6a80fcd0dd574"