Ver código fonte

Merge commit 'b83ae5bfc98edd7b76c43e3cb9fbbc9718ccc2ff' into fix/107222-unable-to-update-empty-pages

Shun Miyazawa 3 anos atrás
pai
commit
c87bff719d
57 arquivos alterados com 709 adições e 412 exclusões
  1. 1 1
      lerna.json
  2. 1 1
      package.json
  3. 1 0
      packages/app/docker/Dockerfile
  4. 14 10
      packages/app/package.json
  5. 3 3
      packages/app/public/static/locales/en_US/translation.json
  6. 3 3
      packages/app/public/static/locales/ja_JP/translation.json
  7. 3 3
      packages/app/public/static/locales/zh_CN/translation.json
  8. 4 45
      packages/app/src/client/util/smooth-scroll.ts
  9. 2 4
      packages/app/src/components/Admin/FullTextSearchManagement.tsx
  10. 11 12
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  11. 3 4
      packages/app/src/components/Admin/ImportData/GrowiArchiveSection.jsx
  12. 10 9
      packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx
  13. 1 1
      packages/app/src/components/Admin/Security/GitHubSecuritySettingContents.jsx
  14. 1 1
      packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx
  15. 2 2
      packages/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  16. 1 1
      packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  17. 1 1
      packages/app/src/components/Admin/Security/TwitterSecuritySettingContents.jsx
  18. 1 1
      packages/app/src/components/AlertSiteUrlUndefined.tsx
  19. 33 12
      packages/app/src/components/Common/ImageCropModal.tsx
  20. 22 31
      packages/app/src/components/ContentLinkButtons.tsx
  21. 35 24
      packages/app/src/components/Fab.tsx
  22. 2 25
      packages/app/src/components/ForbiddenPage.tsx
  23. 39 19
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  24. 4 2
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  25. 2 1
      packages/app/src/components/Page/DisplaySwitcher.tsx
  26. 14 8
      packages/app/src/components/Page/RevisionLoader.tsx
  27. 60 56
      packages/app/src/components/PageComment.tsx
  28. 0 1
      packages/app/src/components/PageComment/Comment.tsx
  29. 1 3
      packages/app/src/components/PageComment/ReplyComments.tsx
  30. 0 1
      packages/app/src/components/PageTimeline.tsx
  31. 14 9
      packages/app/src/components/ReactMarkdownComponents/Header.tsx
  32. 2 1
      packages/app/src/components/ReactMarkdownComponents/NextLink.tsx
  33. 58 30
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  34. 4 2
      packages/app/src/pages/admin/[[...path]].page.tsx
  35. 2 1
      packages/app/src/pages/share/[[...path]].page.tsx
  36. 45 5
      packages/app/src/pages/trash.page.tsx
  37. 10 27
      packages/app/src/server/routes/hackmd.js
  38. 6 8
      packages/app/src/server/routes/index.js
  39. 1 1
      packages/app/src/server/routes/page.js
  40. 11 9
      packages/app/src/services/renderer/rehype-plugins/keyword-highlighter.ts
  41. 14 14
      packages/app/test/cypress/integration/30-search/search.spec.ts
  42. 1 1
      packages/codemirror-textlint/package.json
  43. 1 1
      packages/core/package.json
  44. 1 0
      packages/hackmd/.gitignore
  45. 16 0
      packages/hackmd/package.json
  46. 1 1
      packages/hackmd/src/hackmd-agent.js
  47. 1 1
      packages/hackmd/src/hackmd-styles.js
  48. 15 0
      packages/hackmd/src/index.js
  49. 0 0
      packages/hackmd/src/styles.scss
  50. 20 0
      packages/hackmd/vite.config.js
  51. 1 1
      packages/plugin-attachment-refs/package.json
  52. 4 4
      packages/plugin-lsx/package.json
  53. 1 1
      packages/remark-growi-plugin/package.json
  54. 1 1
      packages/slack/package.json
  55. 2 2
      packages/slackbot-proxy/package.json
  56. 2 2
      packages/ui/package.json
  57. 200 5
      yarn.lock

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "6.0.0-RC.3",
+  "version": "6.0.0-RC.7",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.0.0-RC.3",
+  "version": "6.0.0-RC.7",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 1 - 0
packages/app/docker/Dockerfile

@@ -110,6 +110,7 @@ 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
+COPY packages/hackmd packages/hackmd
 
 # build
 RUN yarn lerna run build

+ 14 - 10
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.0.0-RC.3",
+  "version": "6.0.0-RC.7",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -45,6 +45,7 @@
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
+    "resources:hackmd": "yarn lerna run build --scope=@growi/hackmd",
     "resources:dummy": "true",
     "// resources:plugin": "yarn ts-node bin/generate-plugin-definitions-source.ts",
     "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
@@ -64,11 +65,12 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^6.0.0-RC.3",
-    "@growi/core": "^6.0.0-RC.3",
-    "@growi/plugin-attachment-refs": "^6.0.0-RC.3",
-    "@growi/plugin-lsx": "^6.0.0-RC.3",
-    "@growi/slack": "^6.0.0-RC.3",
+    "@growi/codemirror-textlint": "^6.0.0-RC.7",
+    "@growi/core": "^6.0.0-RC.7",
+    "@growi/hackmd": "^6.0.0-RC.7",
+    "@growi/plugin-attachment-refs": "^6.0.0-RC.7",
+    "@growi/plugin-lsx": "^6.0.0-RC.7",
+    "@growi/slack": "^6.0.0-RC.7",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -97,6 +99,7 @@
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
     "diff_match_patch": "^0.1.1",
+    "ejs": "^3.1.8",
     "entities": "^2.0.0",
     "esa-node": "^0.2.2",
     "escape-string-regexp": "=4.0.0",
@@ -159,9 +162,9 @@
     "react-image-crop": "^8.3.0",
     "react-markdown": "^8.0.3",
     "react-multiline-clamp": "^2.0.0",
+    "react-scroll": "^1.8.7",
     "react-syntax-highlighter": "^15.5.0",
     "react-use-ripple": "^1.5.2",
-    "react-scroll": "^1.8.7",
     "reactstrap": "^8.9.0",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
@@ -189,13 +192,13 @@
     "toastr": "^2.1.2",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
+    "unstated": "^2.1.1",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "validator": "^13.7.0",
     "ws": "^8.3.0",
-    "xss": "^1.0.6",
-    "unstated": "^2.1.1"
+    "xss": "^1.0.6"
   },
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
@@ -203,7 +206,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^6.0.0-RC.3",
+    "@growi/ui": "^6.0.0-RC.7",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",
@@ -211,6 +214,7 @@
     "@types/express": "^4.17.11",
     "@types/jquery": "^3.5.8",
     "@types/multer": "^1.4.5",
+    "@types/react-scroll": "^1.8.4",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
     "bootstrap": "^4.6.1",

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

@@ -184,8 +184,7 @@
     "could_not_creata_path": "Couldn't create path."
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page.",
-    "link_sharing_is_disabled": "Link sharing is disabled."
+    "no_page_list": "There are no pages under this page."
   },
   "installer": {
     "setup": "Setup",
@@ -254,7 +253,8 @@
     "Unlimited": "unlimited",
     "Issue": "Issue",
     "share_settings" :"Share settings",
-    "Invalid_Number_of_Date" : "You entered invalid value"
+    "Invalid_Number_of_Date" : "You entered invalid value",
+    "link_sharing_is_disabled": "Link sharing is disabled"
   },
   "API Settings": "API settings",
   "API Token Settings": "API token settings",

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

@@ -177,8 +177,7 @@
     "could_not_creata_path": "パスを作成できませんでした。"
   },
   "custom_navigation": {
-    "no_page_list": "このページの配下にはページが存在しません。",
-    "link_sharing_is_disabled": "リンクのシェアは無効化されています"
+    "no_page_list": "このページの配下にはページが存在しません。"
   },
   "installer": {
     "setup": "セットアップ",
@@ -247,7 +246,8 @@
     "Unlimited": "無期限",
     "Issue": "発行",
     "share_settings" :"共有設定",
-    "Invalid_Number_of_Date" : "有効期限の日数には整数を入力してください"
+    "Invalid_Number_of_Date" : "有効期限の日数には整数を入力してください",
+    "link_sharing_is_disabled": "リンクのシェアは無効化されています"
   },
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",

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

@@ -179,8 +179,7 @@
     "could_not_creata_path": "无法创建路径"
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page.",
-    "link_sharing_is_disabled": "链接共享已被禁用"
+    "no_page_list": "There are no pages under this page."
   },
 	"installer": {
 		"setup": "安装",
@@ -600,7 +599,8 @@
     "Unlimited": "unlimited",
     "Issue": "Issue",
     "share_settings" :"Share settings",
-    "Invalid_Number_of_Date" : "You entered invalid value"
+    "Invalid_Number_of_Date" : "You entered invalid value",
+    "link_sharing_is_disabled": "链接共享已被禁用"
   },
 	"notification_setting": {
 		"slack_incoming_configuration": "Slack Incoming Webhooks configuration",

+ 4 - 45
packages/app/src/client/util/smooth-scroll.ts

@@ -1,46 +1,5 @@
-const WIKI_HEADER_LINK = 120;
-
-export const smoothScrollIntoView = (
-    element: HTMLElement = window.document.body, offsetTop = 0, scrollElement: HTMLElement | Window = window,
-): void => {
-
-  // get the distance to the target element top
-  const rectTop = element.getBoundingClientRect().top;
-
-  const top = window.pageYOffset + rectTop - offsetTop;
-
-  scrollElement.scrollTo({
-    top,
-    behavior: 'smooth',
-  });
-};
-
-export type SmoothScrollEventCallback = (elem: HTMLElement) => void;
-
-export const addSmoothScrollEvent = (elements: HTMLAnchorElement[], callback?: SmoothScrollEventCallback): void => {
-  elements.forEach((link) => {
-    const href = link.getAttribute('href');
-
-    if (href == null) {
-      return;
-    }
-
-    link.addEventListener('click', (e) => {
-      e.preventDefault();
-
-      // modify location.hash without scroll
-      window.history.pushState({}, '', link.href);
-
-      // smooth scroll
-      const elemId = href.replace('#', '');
-      const targetDom = document.getElementById(elemId);
-      if (targetDom != null) {
-        smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
-
-        if (callback != null) {
-          callback(targetDom);
-        }
-      }
-    });
-  });
+// option object for react-scroll
+export const DEFAULT_AUTO_SCROLL_OPTS = {
+  smooth: 'easeOutQuint',
+  duration: 1200,
 };

+ 2 - 4
packages/app/src/components/Admin/FullTextSearchManagement.tsx

@@ -4,8 +4,8 @@ import { useTranslation } from 'next-i18next';
 
 import ElasticsearchManagement from './ElasticsearchManagement/ElasticsearchManagement';
 
-const FullTextSearchManagement = (): JSX.Element => {
-  const { t } = useTranslation();
+export const FullTextSearchManagement = (): JSX.Element => {
+  const { t } = useTranslation('admin');
 
   return (
     <div data-testid="admin-full-text-search">
@@ -14,5 +14,3 @@ const FullTextSearchManagement = (): JSX.Element => {
     </div>
   );
 };
-
-export default FullTextSearchManagement;

+ 11 - 12
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -3,14 +3,12 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
-import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
 import ImportOptionForPages from '~/models/admin/import-option-for-pages';
 import ImportOptionForRevisions from '~/models/admin/import-option-for-revisions';
-
-import { withUnstatedContainers } from '../../../UnstatedUtils';
+import { useAdminSocket } from '~/stores/socket-io';
 
 
 import ErrorViewer from './ErrorViewer';
@@ -103,7 +101,7 @@ class ImportForm extends React.Component {
   }
 
   setupWebsocketEventHandler() {
-    const socket = this.props.adminSocketIoContainer.getSocket();
+    const { socket } = this.props;
 
     // websocket event
     // eslint-disable-next-line object-curly-newline
@@ -143,7 +141,7 @@ class ImportForm extends React.Component {
   }
 
   teardownWebsocketEventHandler() {
-    const socket = this.props.adminSocketIoContainer.getSocket();
+    const { socket } = this.props;
 
     socket.removeAllListeners('admin:onProgressForImport');
     socket.removeAllListeners('admin:onTerminateForImport');
@@ -496,7 +494,7 @@ class ImportForm extends React.Component {
 
 ImportForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
+  socket: PropTypes.object.isRequired,
 
   fileName: PropTypes.string,
   innerFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
@@ -506,13 +504,14 @@ ImportForm.propTypes = {
 
 const ImportFormWrapperFc = (props) => {
   const { t } = useTranslation('admin');
+  const { data: socket } = useAdminSocket();
+
+  if (socket == null) {
+    return;
+  }
 
-  return <ImportForm t={t} {...props} />;
+  return <ImportForm t={t} socket={socket} {...props} />;
 };
 
-/**
- * Wrapper component for using unstated
- */
-const ImportFormWrapper = withUnstatedContainers(ImportFormWrapperFc, [AdminSocketIoContainer]);
 
-export default ImportFormWrapper;
+export default ImportFormWrapperFc;

+ 3 - 4
packages/app/src/components/Admin/ImportData/GrowiArchiveSection.jsx

@@ -8,7 +8,7 @@ import { apiv3Delete, apiv3Get } from '~/client/util/apiv3-client';
 
 // import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
-// import ImportForm from './GrowiArchive/ImportForm';
+import ImportForm from './GrowiArchive/ImportForm';
 import UploadForm from './GrowiArchive/UploadForm';
 
 class GrowiArchiveSection extends React.Component {
@@ -129,12 +129,11 @@ class GrowiArchiveSection extends React.Component {
         {isTheSameVersion === false && this.renderDefferentVersionAlert()}
         {this.state.fileName != null && isTheSameVersion === true ? (
           <div className="px-4">
-            {/* show ImportForm by https://redmine.weseek.co.jp/issues/100061 */}
-            {/* <ImportForm
+            <ImportForm
               fileName={this.state.fileName}
               innerFileStats={this.state.innerFileStats}
               onDiscard={this.discardData}
-            /> */}
+            />
           </div>
         )
           : (

+ 10 - 9
packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx

@@ -32,26 +32,27 @@ const ManageGlobalNotification = (props) => {
   const [slackChannelToSend, setSlackChannelToSend] = useState('');
   const [triggerEvents, setTriggerEvents] = useState(new Set(globalNotification?.triggerEvents));
 
-  const onChangeTriggerEvents = (triggerEvent) => {
+  const onChangeTriggerEvents = useCallback((triggerEvent) => {
+    let newTriggerEvents;
 
     if (triggerEvents.has(triggerEvent)) {
-      triggerEvents.delete(triggerEvent);
-      setTriggerEvents(triggerEvents);
+      newTriggerEvents = ([...triggerEvents].filter(item => item !== triggerEvent));
+      setTriggerEvents(new Set(newTriggerEvents));
     }
     else {
-      triggerEvents.add(triggerEvent);
-      setTriggerEvents(triggerEvents);
+      newTriggerEvents = [...triggerEvents, triggerEvent];
+      setTriggerEvents(new Set(newTriggerEvents));
     }
-  };
+  }, [triggerEvents]);
 
   const submitHandler = useCallback(async() => {
 
     const requestParams = {
       triggerPath,
       notifyToType,
-      emailToSend,
-      slackChannelToSend,
-      triggerEvents,
+      toEmail: emailToSend,
+      slackChannels: slackChannelToSend,
+      triggerEvents: [...triggerEvents],
     };
 
     try {

+ 1 - 1
packages/app/src/components/Admin/Security/GitHubSecuritySettingContents.jsx

@@ -90,7 +90,7 @@ class GitHubSecurityManagementContents extends React.Component {
                 <i
                   className="icon-exclamation"
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('security_settings.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                  dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
                 />
               </div>
             )}

+ 1 - 1
packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx

@@ -88,7 +88,7 @@ class GoogleSecurityManagementContents extends React.Component {
                 <i
                   className="icon-exclamation"
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('security_settings.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                  dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
                 />
               </div>
             )}

+ 2 - 2
packages/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx

@@ -82,7 +82,7 @@ class OidcSecurityManagementContents extends React.Component {
                 <i
                   className="icon-exclamation"
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('security_settings.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                  dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
                 />
               </div>
             )}
@@ -378,7 +378,7 @@ class OidcSecurityManagementContents extends React.Component {
                     <i
                       className="icon-exclamation"
                       // eslint-disable-next-line max-len
-                      dangerouslySetInnerHTML={{ __html: t('security_settings.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                      dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
                     />
                   </div>
                 )}

+ 1 - 1
packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx

@@ -99,7 +99,7 @@ class SamlSecurityManagementContents extends React.Component {
                 <i
                   className="icon-exclamation"
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('security_settings.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                  dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
                 />
               </div>
             )}

+ 1 - 1
packages/app/src/components/Admin/Security/TwitterSecuritySettingContents.jsx

@@ -90,7 +90,7 @@ class TwitterSecuritySettingContents extends React.Component {
                 <i
                   className="icon-exclamation"
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('security_settings.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
+                  dangerouslySetInnerHTML={{ __html: t('commons:alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('commons:headers.app_settings')}<i class="icon-login"></i></a>` }) }}
                 />
               </div>
             )}

+ 1 - 1
packages/app/src/components/AlertSiteUrlUndefined.tsx

@@ -30,7 +30,7 @@ export const AlertSiteUrlUndefined = (): JSX.Element => {
     <div className="alert alert-danger rounded-0 d-edit-none mb-0 px-4 py-2">
       <i className="icon-exclamation"></i>
       {
-        t('common:alert.alert_siteUrl_is_not_set', { link: t('commons:headers.app_settings') })
+        t('commons:alert.siteUrl_is_not_set', { link: t('commons:headers.app_settings') })
       } &gt;&gt; <a href="/admin/app">{t('commons:headers.app_settings')}<i className="icon-login"></i></a>
     </div>
   );

+ 33 - 12
packages/app/src/components/Common/ImageCropModal.tsx

@@ -49,6 +49,14 @@ const ImageCropModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const reset = useCallback(() => {
     if (imageRef) {
+      // Some SVG files may not have width and height properties, causing the render size to be 0x0
+      // Force imageRef to have width and height by create temporary image element then set the imageRef width with tempImage width
+      // Set imageRef width & height by natural width / height if image has no dimension
+      if (imageRef.width === 0 || imageRef.height === 0) {
+        imageRef.width = imageRef.naturalWidth;
+        imageRef.height = imageRef.naturalHeight;
+      }
+      // Get size of Image, min value of width and height
       const size = Math.min(imageRef.width, imageRef.height);
       setCropOtions({
         aspect: 1,
@@ -63,6 +71,7 @@ const ImageCropModal: FC<Props> = (props: Props) => {
 
   useEffect(() => {
     document.body.style.position = 'static';
+    setIsCropImage(true);
     reset();
   }, [reset]);
 
@@ -73,18 +82,22 @@ const ImageCropModal: FC<Props> = (props: Props) => {
   };
 
 
-  const onCropChange = (crop) => {
-    setCropOtions(crop);
-  };
+  const getCroppedImg = async(image: HTMLImageElement, crop: ICropOptions) => {
+    const {
+      naturalWidth: imageNaturalWidth, naturalHeight: imageNaturalHeight, width: imageWidth, height: imageHeight,
+    } = image;
+
+    const {
+      width: cropWidth, height: cropHeight, x, y,
+    } = crop;
 
-  const getCroppedImg = async(image, crop) => {
     const canvas = document.createElement('canvas');
-    const scaleX = image.naturalWidth / image.width;
-    const scaleY = image.naturalHeight / image.height;
-    canvas.width = crop.width;
-    canvas.height = crop.height;
+    const scaleX = imageNaturalWidth / imageWidth;
+    const scaleY = imageNaturalHeight / imageHeight;
+    canvas.width = cropWidth;
+    canvas.height = cropHeight;
     const ctx = canvas.getContext('2d');
-    ctx?.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width, crop.height);
+    ctx?.drawImage(image, x * scaleX, y * scaleY, cropWidth * scaleX, cropHeight * scaleY, 0, 0, cropWidth, cropHeight);
     try {
       const blob = await canvasToBlob(canvas);
       return blob;
@@ -105,7 +118,6 @@ const ImageCropModal: FC<Props> = (props: Props) => {
   // Clear image and set isImageCrop true on modal close
   const onModalCloseHandler = async() => {
     setImageRef(null);
-    setIsCropImage(true);
     onModalClose();
   };
 
@@ -129,12 +141,21 @@ const ImageCropModal: FC<Props> = (props: Props) => {
       <ModalBody className="my-4">
         {
           isCropImage
-            ? (<ReactCrop src={src} crop={cropOptions} onImageLoaded={onImageLoaded} onChange={onCropChange} circularCrop={isCircular} />)
+            ? (
+              <ReactCrop
+                style={{ backgroundColor: 'transparent' }}
+                src={src}
+                crop={cropOptions}
+                onImageLoaded={onImageLoaded}
+                onChange={crop => setCropOtions(crop)}
+                circularCrop={isCircular}
+              />
+            )
             : (<img style={{ maxWidth: imageRef?.width }} src={imageRef?.src} />)
         }
       </ModalBody>
       <ModalFooter>
-        <button type="button" className="btn btn-outline-danger rounded-pill mr-auto" onClick={reset}>
+        <button type="button" className="btn btn-outline-danger rounded-pill mr-auto" disabled={!isCropImage} onClick={reset}>
           {t('crop_image_modal.reset')}
         </button>
         { !showCropOption && (

+ 22 - 31
packages/app/src/components/ContentLinkButtons.tsx

@@ -1,31 +1,27 @@
-import React, { useCallback } from 'react';
+import React from 'react';
 
 import { IUserHasId } from '@growi/core';
+import { Link as ScrollLink } from 'react-scroll';
 
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 
 import styles from './ContentLinkButtons.module.scss';
 
-const WIKI_HEADER_LINK = 120;
+const OFFSET = -120;
 
 const BookMarkLinkButton = React.memo(() => {
 
-  const BookMarkLinkButtonClickHandler = useCallback(() => {
-    const getBookMarkListHeaderDom = document.getElementById('bookmarks-list');
-    if (getBookMarkListHeaderDom == null) { return }
-    smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK);
-  }, []);
-
   return (
-    <button
-      type="button"
-      className="btn btn-outline-secondary btn-sm px-2"
-      onClick={BookMarkLinkButtonClickHandler}
-    >
-      <i className="fa fa-fw fa-bookmark-o"></i>
-      <span>Bookmarks</span>
-    </button>
+    <ScrollLink to="bookmarks-list" offset={OFFSET} {...DEFAULT_AUTO_SCROLL_OPTS}>
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-2"
+      >
+        <i className="fa fa-fw fa-bookmark-o"></i>
+        <span>Bookmarks</span>
+      </button>
+    </ScrollLink>
   );
 });
 
@@ -33,21 +29,16 @@ BookMarkLinkButton.displayName = 'BookMarkLinkButton';
 
 const RecentlyCreatedLinkButton = React.memo(() => {
 
-  const RecentlyCreatedListButtonClickHandler = useCallback(() => {
-    const getRecentlyCreatedListHeaderDom = document.getElementById('recently-created-list');
-    if (getRecentlyCreatedListHeaderDom == null) { return }
-    smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK);
-  }, []);
-
   return (
-    <button
-      type="button"
-      className="btn btn-outline-secondary btn-sm px-3"
-      onClick={RecentlyCreatedListButtonClickHandler}
-    >
-      <i className={`${styles['grw-icon-container-recently-created']} grw-icon-container-recently-created mr-2`}><RecentlyCreatedIcon /></i>
-      <span>Recently Created</span>
-    </button>
+    <ScrollLink to="recently-created-list" offset={OFFSET} {...DEFAULT_AUTO_SCROLL_OPTS}>
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-3"
+      >
+        <i className={`${styles['grw-icon-container-recently-created']} grw-icon-container-recently-created mr-2`}><RecentlyCreatedIcon /></i>
+        <span>Recently Created</span>
+      </button>
+    </ScrollLink>
   );
 });
 

+ 35 - 24
packages/app/src/components/Fab.tsx

@@ -1,11 +1,12 @@
 import React, {
-  useState, useCallback, useEffect, useRef,
+  useState, useCallback, useRef,
 } from 'react';
 
+import { animateScroll } from 'react-scroll';
 import { useRipple } from 'react-use-ripple';
 import StickyEvents from 'sticky-events';
 
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
@@ -59,39 +60,49 @@ export const Fab = (): JSX.Element => {
   //   };
   // }, [stickyChangeHandler]);
 
-  if (currentPath == null) {
-    return <></>;
-  }
-
-  const renderPageCreateButton = () => {
+  const PageCreateButton = useCallback(() => {
     return (
-      <>
-        <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
-          <button
-            type="button"
-            className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 ${buttonClasses}`}
-            ref={createBtnRef}
-            onClick={() => openCreateModal(currentPath)}
-          >
-            <CreatePageIcon />
-          </button>
-        </div>
-      </>
+      <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
+        <button
+          type="button"
+          className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 ${buttonClasses}`}
+          ref={createBtnRef}
+          onClick={currentPath != null
+            ? () => openCreateModal(currentPath)
+            : undefined}
+        >
+          <CreatePageIcon />
+        </button>
+      </div>
     );
-  };
+  }, [animateClasses, buttonClasses, currentPath, openCreateModal]);
 
-  return (
-    <div className={`${styles['grw-fab']} grw-fab d-none d-md-block d-edit-none`} data-testid="grw-fab-container">
-      {currentUser != null && renderPageCreateButton()}
+  const ScrollToTopButton = useCallback(() => {
+    const clickHandler = () => {
+      animateScroll.scrollToTop(DEFAULT_AUTO_SCROLL_OPTS);
+    };
+
+    return (
       <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }} data-testid="grw-fab-return-to-top">
         <button
           type="button"
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}
-          onClick={() => smoothScrollIntoView()}
+          onClick={clickHandler}
         >
           <ReturnTopIcon />
         </button>
       </div>
+    );
+  }, [animateClasses, buttonClasses]);
+
+  if (currentPath == null) {
+    return <></>;
+  }
+
+  return (
+    <div className={`${styles['grw-fab']} grw-fab d-none d-md-block d-edit-none`} data-testid="grw-fab-container">
+      {currentUser != null && <PageCreateButton />}
+      <ScrollToTopButton />
     </div>
   );
 

+ 2 - 25
packages/app/src/components/ForbiddenPage.tsx

@@ -1,12 +1,7 @@
-import React, { useMemo } from 'react';
+import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
-import PageListIcon from './Icons/PageListIcon';
-
-
 type Props = {
   isLinkSharingDisabled?: boolean,
 }
@@ -14,17 +9,6 @@ type Props = {
 const ForbiddenPage = React.memo((props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const navTabMapping = useMemo(() => {
-    return {
-      pagelist: {
-        Icon: PageListIcon,
-        Content: DescendantsPageListForCurrentPath,
-        i18n: t('page_list'),
-        index: 0,
-      },
-    };
-  }, [t]);
-
   return (
     <>
       <div className="row not-found-message-row mb-4">
@@ -40,17 +24,10 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
             <i className="icon-fw icon-lock" aria-hidden="true" />
-            { props.isLinkSharingDisabled ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
+            { props.isLinkSharingDisabled ? t('share_links.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
           </p>
         </div>
       </div>
-
-      { !props.isLinkSharingDisabled && (
-        <div className="mt-5">
-          <CustomNavAndContents navTabMapping={navTabMapping} />
-        </div>
-      ) }
-
     </>
   );
 });

+ 39 - 19
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -61,25 +61,19 @@ const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
   loading: AuthorInfoSkelton,
 });
 
-type AdditionalMenuItemsProps = {
+type PageOperationMenuItemsProps = {
   pageId: string,
   revisionId: string,
   isLinkSharingDisabled?: boolean,
-  onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void,
-
 }
 
-const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
+const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    pageId, revisionId, isLinkSharingDisabled, onClickTemplateMenuItem,
+    pageId, revisionId, isLinkSharingDisabled,
   } = props;
 
-  const openPageTemplateModalHandler = () => {
-    onClickTemplateMenuItem(true);
-  };
-
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
 
@@ -151,9 +145,25 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
         </span>
         {t('share_links.share_link_management')}
       </DropdownItem>
+    </>
+  );
+};
 
-      <DropdownItem divider />
+type CreateTemplateMenuItemsProps = {
+  onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void,
+}
 
+const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { onClickTemplateMenuItem } = props;
+
+  const openPageTemplateModalHandler = () => {
+    onClickTemplateMenuItem(true);
+  };
+
+  return (
+    <>
       {/* Create template */}
       <DropdownItem
         onClick={openPageTemplateModalHandler}
@@ -175,7 +185,6 @@ type GrowiContextualSubNavigationProps = {
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
-  const path = currentPage?.path;
 
   const revision = currentPage?.revision;
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
@@ -203,6 +212,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: templateTagData } = useTemplateTagData();
 
+  const path = currentPage?.path ?? currentPathname;
 
   useEffect(() => {
     // Run only when tagsInfoData has been updated
@@ -306,15 +316,25 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const RightComponent = useCallback(() => {
     const additionalMenuItemsRenderer = () => {
       if (revisionId == null || pageId == null) {
-        return <></>;
+        return (
+          <>
+            <CreateTemplateMenuItems
+              onClickTemplateMenuItem={templateMenuItemClickHandler}
+            />
+          </>);
       }
       return (
-        <AdditionalMenuItems
-          pageId={pageId}
-          revisionId={revisionId}
-          isLinkSharingDisabled={isLinkSharingDisabled}
-          onClickTemplateMenuItem={templateMenuItemClickHandler}
-        />
+        <>
+          <PageOperationMenuItems
+            pageId={pageId}
+            revisionId={revisionId}
+            isLinkSharingDisabled={isLinkSharingDisabled}
+          />
+          <DropdownItem divider />
+          <CreateTemplateMenuItems
+            onClickTemplateMenuItem={templateMenuItemClickHandler}
+          />
+        </>
       );
     };
 
@@ -377,7 +397,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       </>
     );
   // eslint-disable-next-line max-len
-  }, [isCompactMode, isViewMode, pageId, revisionId, shareLinkId, path, isSharedUser, isAbleToShowPageManagement, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, isAbleToShowPageEditorModeManager, isGuestUser, editorMode, isAbleToShowPageAuthors, currentPage, currentUser, isPageTemplateModalShown, isLinkSharingDisabled, templateMenuItemClickHandler, mutateEditorMode]);
+  }, [isCompactMode, isViewMode, pageId, revisionId, shareLinkId, path, currentPathname, isSharedUser, isAbleToShowPageManagement, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, isAbleToShowPageEditorModeManager, isGuestUser, editorMode, isAbleToShowPageAuthors, currentPage, currentUser, isPageTemplateModalShown, isLinkSharingDisabled, templateMenuItemClickHandler, mutateEditorMode]);
 
 
   const pagePath = isNotFound

+ 4 - 2
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -32,7 +32,7 @@ export type GrowiSubNavigationProps = {
   isCompactMode?: boolean,
   tags?: string[],
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void> | void,
-  rightComponent: React.FunctionComponent,
+  rightComponent?: React.FunctionComponent,
   additionalClasses?: string[],
 }
 
@@ -81,7 +81,9 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
         </div>
       </div>
       {/* Right side. */}
-      <RightComponent />
+      { RightComponent && (
+        <RightComponent />
+      ) }
     </div>
   );
 };

+ 2 - 1
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { Link } from 'react-scroll';
 
+import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import {
   useCurrentPagePath, useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
@@ -81,7 +82,7 @@ const PageView = React.memo((): JSX.Element => {
             {/* Comments */}
             { !isTopPagePath && (
               <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
-                <Link to={'page-comments'} smooth="easeOutQuart" offset={-100} duration={800}>
+                <Link to={'page-comments'} offset={-100} {...DEFAULT_AUTO_SCROLL_OPTS}>
                   <button
                     type="button"
                     className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"

+ 14 - 8
packages/app/src/components/Page/RevisionLoader.tsx

@@ -10,19 +10,23 @@ import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './RevisionRenderer';
 
+export const ROOT_ELEM_ID = 'revision-loader' as const;
+
 export type RevisionLoaderProps = {
   rendererOptions: RendererOptions,
   pageId: string,
   revisionId: Ref<IRevision>,
   lazy?: boolean,
   onRevisionLoaded?: (revision: IRevisionHasId) => void,
-
-  pagePath: string,
-  highlightKeywords?: string[],
 }
 
 const logger = loggerFactory('growi:Page:RevisionLoader');
 
+// Always render '#revision-loader' for MutationObserver of SearchResultContent
+const RevisionLoaderRoot = (props: React.HTMLAttributes<HTMLDivElement>): JSX.Element => (
+  <div id={ROOT_ELEM_ID} {...props}>{props.children}</div>
+);
+
 /**
  * Load data from server and render RevisionBody component
  */
@@ -81,7 +85,7 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
   if (lazy && !isLoaded) {
     return (
       <Waypoint onPositionChange={onWaypointChange} bottomOffset="-100px">
-        <div className="wiki"></div>
+        <></>
       </Waypoint>
     );
   }
@@ -110,9 +114,11 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
   }
 
   return (
-    <RevisionRenderer
-      rendererOptions={rendererOptions}
-      markdown={markdown}
-    />
+    <RevisionLoaderRoot>
+      <RevisionRenderer
+        rendererOptions={rendererOptions}
+        markdown={markdown}
+      />
+    </RevisionLoaderRoot>
   );
 };

+ 60 - 56
packages/app/src/components/PageComment.tsx

@@ -27,6 +27,14 @@ const DeleteCommentModal = dynamic<DeleteCommentModalProps>(
   () => import('./PageComment/DeleteCommentModal').then(mod => mod.DeleteCommentModal), { ssr: false },
 );
 
+export const ROOT_ELEM_ID = 'page-comments' as const;
+
+// Always render '#page-comments' for MutationObserver of SearchResultContent
+const PageCommentRoot = (props: React.HTMLAttributes<HTMLDivElement>): JSX.Element => (
+  <div id={ROOT_ELEM_ID} {...props}>{props.children}</div>
+);
+
+
 export type PageCommentProps = {
   rendererOptions?: RendererOptions,
   pageId: string,
@@ -34,7 +42,6 @@ export type PageCommentProps = {
   currentUser: any,
   isReadOnly: boolean,
   titleAlign?: 'center' | 'left' | 'right',
-  highlightKeywords?: string[],
   hideIfEmpty?: boolean,
 }
 
@@ -42,7 +49,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
   const {
     rendererOptions: rendererOptionsByProps,
-    pageId, revision, currentUser, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
+    pageId, revision, currentUser, isReadOnly, titleAlign, hideIfEmpty,
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
@@ -103,7 +110,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   }, []);
 
   if (hideIfEmpty && comments?.length === 0) {
-    return <></>;
+    return <PageCommentRoot />;
   }
 
   let commentTitleClasses = 'border-bottom py-3 mb-3';
@@ -113,7 +120,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
   if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
     if (hideIfEmpty) {
-      return <></>;
+      return <PageCommentRoot />;
     }
     return (
       <PageCommentSkelton commentTitleClasses={commentTitleClasses}/>
@@ -131,7 +138,6 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       revisionCreatedAt={revisionCreatedAt as Date}
       currentUser={currentUser}
       isReadOnly={isReadOnly}
-      highlightKeywords={highlightKeywords}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
     />
@@ -151,57 +157,55 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   );
 
   return (
-    <>
-      <div id="page-comments" className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
-        <div className="container-lg">
-          <div className="page-comments">
-            <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
-            <div className="page-comments-list" id="page-comments-list">
-              { commentsExceptReply.map((comment) => {
-
-                const defaultCommentThreadClasses = 'page-comment-thread pb-5';
-                const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
-
-                let commentThreadClasses = '';
-                commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
-
-                return (
-                  <div key={comment._id} className={commentThreadClasses}>
-                    {generateCommentElement(comment)}
-                    {hasReply && generateReplyCommentsElement(allReplies[comment._id])}
-                    {(!isReadOnly && !showEditorIds.has(comment._id)) && (
-                      <div className="text-right">
-                        <Button
-                          outline
-                          color="secondary"
-                          size="sm"
-                          className="btn-comment-reply"
-                          onClick={() => {
-                            setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
-                          }}
-                        >
-                          <i className="icon-fw icon-action-undo"></i> Reply
-                        </Button>
-                      </div>
-                    )}
-                    {(!isReadOnly && showEditorIds.has(comment._id)) && (
-                      <CommentEditor
-                        pageId={pageId}
-                        replyTo={comment._id}
-                        onCancelButtonClicked={() => {
-                          removeShowEditorId(comment._id);
-                        }}
-                        onCommentButtonClicked={() => {
-                          removeShowEditorId(comment._id);
-                          mutate();
+    <PageCommentRoot className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
+      <div className="container-lg">
+        <div className="page-comments">
+          <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
+          <div className="page-comments-list" id="page-comments-list">
+            { commentsExceptReply.map((comment) => {
+
+              const defaultCommentThreadClasses = 'page-comment-thread pb-5';
+              const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
+
+              let commentThreadClasses = '';
+              commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
+
+              return (
+                <div key={comment._id} className={commentThreadClasses}>
+                  {generateCommentElement(comment)}
+                  {hasReply && generateReplyCommentsElement(allReplies[comment._id])}
+                  {(!isReadOnly && !showEditorIds.has(comment._id)) && (
+                    <div className="text-right">
+                      <Button
+                        outline
+                        color="secondary"
+                        size="sm"
+                        className="btn-comment-reply"
+                        onClick={() => {
+                          setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
                         }}
-                      />
-                    )}
-                  </div>
-                );
-
-              })}
-            </div>
+                      >
+                        <i className="icon-fw icon-action-undo"></i> Reply
+                      </Button>
+                    </div>
+                  )}
+                  {(!isReadOnly && showEditorIds.has(comment._id)) && (
+                    <CommentEditor
+                      pageId={pageId}
+                      replyTo={comment._id}
+                      onCancelButtonClicked={() => {
+                        removeShowEditorId(comment._id);
+                      }}
+                      onCommentButtonClicked={() => {
+                        removeShowEditorId(comment._id);
+                        mutate();
+                      }}
+                    />
+                  )}
+                </div>
+              );
+
+            })}
           </div>
         </div>
       </div>
@@ -214,7 +218,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
           confirmToDelete={onDeleteComment}
         />
       )}
-    </>
+    </PageCommentRoot>
   );
 });
 

+ 0 - 1
packages/app/src/components/PageComment/Comment.tsx

@@ -29,7 +29,6 @@ type CommentProps = {
   revisionCreatedAt: Date,
   currentUser: IUser,
   isReadOnly: boolean,
-  highlightKeywords?: string[],
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
 }

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

@@ -21,7 +21,6 @@ type ReplycommentsProps = {
   revisionCreatedAt: Date,
   currentUser: IUser,
   replyList: ICommentHasIdList,
-  highlightKeywords?: string[],
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
 }
@@ -29,7 +28,7 @@ type ReplycommentsProps = {
 export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
   const {
-    rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList, highlightKeywords,
+    rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList,
     deleteBtnClicked, onComment,
   } = props;
 
@@ -47,7 +46,6 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
           revisionCreatedAt={revisionCreatedAt}
           currentUser={currentUser}
           isReadOnly={isReadOnly}
-          highlightKeywords={highlightKeywords}
           deleteBtnClicked={deleteBtnClicked}
           onComment={onComment}
         />

+ 0 - 1
packages/app/src/components/PageTimeline.tsx

@@ -65,7 +65,6 @@ export const PageTimeline = (): JSX.Element => {
                   lazy
                   rendererOptions={rendererOptions}
                   pageId={page._id}
-                  pagePath={page.path}
                   revisionId={page.revision}
                 />
               </div>

+ 14 - 9
packages/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
 
 import EventEmitter from 'events';
 
@@ -57,19 +57,24 @@ export const Header = (props: HeaderProps): JSX.Element => {
 
   const CustomTag = `h${level}` as keyof JSX.IntrinsicElements;
 
-  // update isActive when hash is changed
+  const activateByHash = useCallback((url: string) => {
+    const hash = (new URL(url, 'https://example.com')).hash.slice(1);
+    setActive(hash === id);
+  }, [id]);
+
+  // init
   useEffect(() => {
-    const handler = (url: string) => {
-      const hash = (new URL(url, 'https://example.com')).hash.slice(1);
-      setActive(hash === id);
-    };
+    activateByHash(window.location.href);
+  }, [activateByHash]);
 
-    router.events.on('hashChangeComplete', handler);
+  // update isActive when hash is changed
+  useEffect(() => {
+    router.events.on('hashChangeComplete', activateByHash);
 
     return () => {
-      router.events.off('hashChangeComplete', handler);
+      router.events.off('hashChangeComplete', activateByHash);
     };
-  }, [id, router.events]);
+  }, [activateByHash, router.events]);
 
   return (
     <CustomTag id={id} className={`revision-head ${styles['revision-head']} ${isActive ? 'blink' : ''}`}>

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

@@ -1,6 +1,7 @@
 import Link, { LinkProps } from 'next/link';
 import { Link as ScrollLink } from 'react-scroll';
 
+import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import { useSiteUrl } from '~/stores/context';
 
 const isAnchorLink = (href: string): boolean => {
@@ -35,7 +36,7 @@ export const NextLink = ({
     const to = href.slice(1);
     return (
       <Link href={href} scroll={false}>
-        <ScrollLink href={href} to={to} className={className} smooth="easeOutQuart" offset={-100} duration={800}>
+        <ScrollLink href={href} to={to} className={className} offset={-100} {...DEFAULT_AUTO_SCROLL_OPTS}>
           {children}
         </ScrollLink>
       </Link>

+ 58 - 30
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,15 +1,16 @@
 import React, {
-  FC, useCallback, useEffect, useRef,
+  FC, useCallback, useEffect, useRef, useState,
 } from 'react';
 
 import { getIdForRef } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import { animateScroll } from 'react-scroll';
 import { DropdownItem } from 'reactstrap';
 
+
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/apiNotification';
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import { IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
@@ -24,8 +25,8 @@ import { useFullTextSearchTermManager } from '~/stores/search';
 import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { GrowiSubNavigationProps } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtonsProps } from '../Navbar/SubNavButtons';
-import { RevisionLoaderProps } from '../Page/RevisionLoader';
-import { PageCommentProps } from '../PageComment';
+import { ROOT_ELEM_ID as RevisionLoaderRoomElemId, RevisionLoaderProps } from '../Page/RevisionLoader';
+import { ROOT_ELEM_ID as PageCommentRootElemId, PageCommentProps } from '../PageComment';
 import { PageContentFooterProps } from '../PageContentFooter';
 
 
@@ -57,8 +58,8 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   );
 };
 
-const SCROLL_OFFSET_TOP = 175; // approximate height of (navigation + subnavigation)
-const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true };
+const SCROLL_OFFSET_TOP = 30;
+const MUTATION_OBSERVER_CONFIG = { childList: true }; // omit 'subtree: true'
 
 type Props ={
   pageWithMeta : IPageWithSearchMeta,
@@ -67,28 +68,26 @@ type Props ={
   forceHideMenuItems?: ForceHideMenuItems,
 }
 
-const scrollTo = (scrollElement:HTMLElement) => {
+const scrollToFirstHighlightedKeyword = (scrollElement: HTMLElement): boolean => {
   // use querySelector to intentionally get the first element found
-  const highlightedKeyword = scrollElement.querySelector('.highlighted-keyword') as HTMLElement | null;
-  if (highlightedKeyword != null) {
-    smoothScrollIntoView(highlightedKeyword, SCROLL_OFFSET_TOP, scrollElement);
+  const toElem = scrollElement.querySelector('.highlighted-keyword') as HTMLElement | null;
+  if (toElem == null) {
+    return false;
   }
-};
 
-const generateObserverCallback = (doScroll: ()=>void) => {
-  return (mutationRecords:MutationRecord[]) => {
-    mutationRecords.forEach((record:MutationRecord) => {
-      const target = record.target as HTMLElement;
-      const targetId = target.id as string;
-      if (targetId !== 'wiki') return;
-      doScroll();
-    });
-  };
+  animateScroll.scrollTo(toElem.offsetTop - SCROLL_OFFSET_TOP, {
+    containerId: scrollElement.id,
+    duration: 200,
+  });
+  return true;
 };
 
 export const SearchResultContent: FC<Props> = (props: Props) => {
 
-  const scrollElementRef = useRef(null);
+  const scrollElementRef = useRef<HTMLDivElement|null>(null);
+
+  const [isRevisionLoaded, setRevisionLoaded] = useState(false);
+  const [isPageCommentLoaded, setPageCommentLoaded] = useState(false);
 
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
@@ -97,19 +96,49 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
-    const scrollElement = scrollElementRef.current as HTMLElement | null;
+    const scrollElement = scrollElementRef.current;
     if (scrollElement == null) return;
 
-    const observerCallback = generateObserverCallback(() => {
-      scrollTo(scrollElement);
-    });
+    const observerCallback = (mutationRecords:MutationRecord[], thisObs: MutationObserver) => {
+      mutationRecords.forEach((record:MutationRecord) => {
+        const target = record.target as HTMLElement;
+
+        // turn on boolean if loaded
+        Array.from(target.children).forEach((child) => {
+          const childId = (child as HTMLElement).id;
+          if (childId === RevisionLoaderRoomElemId) {
+            setRevisionLoaded(true);
+          }
+          else if (childId === PageCommentRootElemId) {
+            setPageCommentLoaded(true);
+          }
+        });
+      });
+    };
 
     const observer = new MutationObserver(observerCallback);
     observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
     return () => {
       observer.disconnect();
     };
-  });
+  }, []);
+
+  useEffect(() => {
+    if (!isRevisionLoaded || !isPageCommentLoaded) {
+      return;
+    }
+    if (scrollElementRef.current == null) {
+      return;
+    }
+
+    const scrollElement = scrollElementRef.current;
+    const isScrollProcessed = scrollToFirstHighlightedKeyword(scrollElement);
+    // retry after 1000ms if highlighted element is absense
+    if (!isScrollProcessed) {
+      setTimeout(() => scrollToFirstHighlightedKeyword(scrollElement), 1000);
+    }
+
+  }, [isPageCommentLoaded, isRevisionLoaded]);
   // *******************************  end  *******************************
 
   const {
@@ -211,20 +240,19 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           additionalClasses={['px-4']}
         />
       </div>
-      <div className="search-result-content-body-container" ref={scrollElementRef}>
+      <div id="search-result-content-body-container" className="search-result-content-body-container" ref={scrollElementRef}>
+        {/* RevisionLoader will render '#revision-loader' after loaded */}
         <RevisionLoader
           rendererOptions={rendererOptions}
           pageId={page._id}
-          pagePath={page.path}
           revisionId={page.revision}
-          highlightKeywords={highlightKeywords}
         />
+        {/* PageComment will render '#page-comment' after loaded */}
         <PageComment
           rendererOptions={rendererOptions}
           pageId={page._id}
           revision={page.revision}
           currentUser={currentUser}
-          highlightKeywords={highlightKeywords}
           isReadOnly
           hideIfEmpty
         />

+ 4 - 2
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -58,7 +58,9 @@ const SlackIntegration = dynamic(() => import('../../components/Admin/SlackInteg
 const LegacySlackIntegration = dynamic(() => import('../../components/Admin/LegacySlackIntegration/LegacySlackIntegration'), { ssr: false });
 const UserManagement = dynamic(() => import('../../components/Admin/UserManagement'), { ssr: false });
 const ManageExternalAccount = dynamic(() => import('../../components/Admin/ManageExternalAccount'), { ssr: false });
-const ElasticsearchManagement = dynamic(() => import('../../components/Admin/ElasticsearchManagement/ElasticsearchManagement'), { ssr: false });
+const FullTextSearchManagement = dynamic(
+  () => import('../../components/Admin/FullTextSearchManagement').then(mod => mod.FullTextSearchManagement), { ssr: false },
+);
 const UserGroupDetailPage = dynamic(() => import('../../components/Admin/UserGroupDetail/UserGroupDetailPage'), { ssr: false });
 const AdminLayout = dynamic(() => import('../../components/Layout/AdminLayout'), { ssr: false });
 // named export
@@ -176,7 +178,7 @@ const AdminPage: NextPage<Props> = (props: Props) => {
     },
     search: {
       title: t('full_text_search_management.full_text_search_management'),
-      component: <ElasticsearchManagement />,
+      component: <FullTextSearchManagement />,
     },
     'audit-log': {
       title: t('audit_log_management.audit_log'),

+ 2 - 1
packages/app/src/pages/share/[[...path]].page.tsx

@@ -91,8 +91,9 @@ const SharedPage: NextPage<Props> = (props: Props) => {
                 </div>
               )}
 
-              { (props.isExpired && !props.disableLinkSharing) && (
+              { (props.isExpired && !props.disableLinkSharing && shareLink != null) && (
                 <div className="container-lg">
+                  <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
                   <h2 className="text-muted mt-4">
                     <i className="icon-ban" aria-hidden="true" />
                     <span> Page is expired</span>

+ 45 - 5
packages/app/src/pages/trash.page.tsx

@@ -2,6 +2,7 @@ import React from 'react';
 
 import type { IUser, IUserHasId } from '@growi/core';
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
@@ -9,16 +10,18 @@ import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
-import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import {
   useCurrentUser, useCurrentPageId, useCurrentPagePath, useCurrentPathname,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL,
+  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser,
 } from '../stores/context';
 
 import {
-  CommonProps, getServerSideCommonProps, useCustomTitle,
+  CommonProps, getServerSideCommonProps, getNextI18NextConfig, useCustomTitle,
 } from './utils/commons';
+import { useCurrentProductNavWidth, useCurrentSidebarContents, useDrawerMode, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed } from '~/stores/ui';
+import { GrowiSubNavigation } from '~/components/Navbar/GrowiSubNavigation';
+import { ISidebarConfig } from '~/interfaces/sidebar-config';
 
 const TrashPageList = dynamic(() => import('~/components/TrashPageList').then(mod => mod.TrashPageList), { ssr: false });
 const EmptyTrashModal = dynamic(() => import('~/components/EmptyTrashModal'), { ssr: false });
@@ -29,8 +32,12 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
-  userUISettings?: IUserUISettings,
   showPageLimitationXL: number,
+
+  // UI
+  userUISettings?: IUserUISettings
+  // Sidebar
+  sidebarConfig: ISidebarConfig,
 };
 
 const TrashPage: NextPage<CommonProps> = (props: Props) => {
@@ -45,13 +52,29 @@ const TrashPage: NextPage<CommonProps> = (props: Props) => {
   useCurrentPathname('/trash');
   useCurrentPagePath('/trash');
 
+  // UserUISettings
+  usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
+  usePreferDrawerModeOnEditByUser(props.userUISettings?.preferDrawerModeOnEditByUser);
+  useSidebarCollapsed(props.userUISettings?.isSidebarCollapsed ?? props.sidebarConfig.isSidebarClosedAtDockMode);
+  useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
+  useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
+
   useShowPageLimitationXL(props.showPageLimitationXL);
 
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: isGuestUser } = useIsGuestUser();
+
   return (
     <>
       <BasicLayout title={useCustomTitle(props, 'GROWI')} >
         <header className="py-0 position-relative">
-          <GrowiContextualSubNavigation isLinkSharingDisabled={false} />
+          <GrowiSubNavigation
+            pagePath="/trash"
+            showDrawerToggler={isDrawerMode}
+            isGuestUser={isGuestUser}
+            isDrawerMode={isDrawerMode}
+            additionalClasses={['container-fluid']}
+          />
         </header>
 
         <div className="grw-container-convertible mb-5 pb-5">
@@ -92,6 +115,22 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
   props.showPageLimitationXL = crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL');
+
+  props.sidebarConfig = {
+    isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
+    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+  };
+}
+
+/**
+ * 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;
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
@@ -109,6 +148,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   }
   await injectUserUISettings(context, props);
   injectServerConfigurations(context, props);
+  await injectNextI18NextConfigurations(context, props, ['translation']);
 
   return {
     props,

+ 10 - 27
packages/app/src/server/routes/hackmd.js

@@ -4,9 +4,12 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:hackmd');
 const path = require('path');
+
+const hackmdFiles = require('@growi/hackmd');
+const axios = require('axios');
+const ejs = require('ejs');
 const fs = require('graceful-fs');
 const swig = require('swig-templates');
-const axios = require('axios');
 
 const ApiResponse = require('../util/apiResponse');
 
@@ -34,14 +37,6 @@ module.exports = function(crowi, app) {
   const Page = crowi.models.Page;
   const pageEvent = crowi.event('page');
 
-  // load GROWI agent script for HackMD
-  const manifest = require(path.join(crowi.publicDir, 'manifest.json'));
-  const agentScriptPath = path.join(crowi.publicDir, manifest['js/hackmd-agent.js']);
-  const stylesScriptPath = path.join(crowi.publicDir, manifest['js/hackmd-styles.js']);
-  // generate swig template
-  let agentScriptContentTpl;
-  let stylesScriptContentTpl;
-
   /**
    * GET /_hackmd/load-agent
    *
@@ -52,10 +47,6 @@ module.exports = function(crowi, app) {
    * @param {object} res
    */
   const loadAgent = function(req, res) {
-    // generate swig template
-    if (agentScriptContentTpl == null) {
-      agentScriptContentTpl = swig.compileFile(agentScriptPath);
-    }
 
     const origin = crowi.appService.getSiteUrl();
 
@@ -63,8 +54,9 @@ module.exports = function(crowi, app) {
     const definitions = {
       origin,
     };
-    // inject
-    const script = agentScriptContentTpl(definitions);
+
+    // inject origin to script
+    const script = ejs.render(hackmdFiles.agentJS, definitions);
 
     res.set('Content-Type', 'application/javascript');
     res.send(script);
@@ -80,22 +72,13 @@ module.exports = function(crowi, app) {
    * @param {object} res
    */
   const loadStyles = function(req, res) {
-    // generate swig template
-    if (stylesScriptContentTpl == null) {
-      stylesScriptContentTpl = swig.compileFile(stylesScriptPath);
-    }
-
-    const styleFilePath = path.join(crowi.publicDir, manifest['styles/style-hackmd.css']);
-    const styles = fs
-      .readFileSync(styleFilePath).toString()
-      .replace(/\s+/g, ' ');
 
     // generate definitions to replace
     const definitions = {
-      styles: escape(styles),
+      styles: hackmdFiles.stylesCSS,
     };
-    // inject
-    const script = stylesScriptContentTpl(definitions);
+    // inject styles to script
+    const script = ejs.render(hackmdFiles.stylesJS, definitions);
 
     res.set('Content-Type', 'application/javascript');
     res.send(script);

+ 6 - 8
packages/app/src/server/routes/index.js

@@ -48,8 +48,7 @@ module.exports = function(crowi, app) {
   const comment = require('./comment')(crowi, app);
   const tag = require('./tag')(crowi, app);
   const search = require('./search')(crowi, app);
-  // == TODO: Replace the code in hackmd.js getting the script path from manifest.json
-  // const hackmd = require('./hackmd')(crowi, app);
+  const hackmd = require('./hackmd')(crowi, app);
   const ogp = require('./ogp')(crowi);
 
   const next = nextFactory(crowi);
@@ -225,12 +224,11 @@ module.exports = function(crowi, app) {
   app.get('/trash/$'                  , loginRequired, (req, res) => res.redirect('/trash'));
   app.get('/trash/*/$'                , loginRequired, injectUserUISettings, page.deletedPageListShowWrapper);
 
-  // == TODO: Replace the code in hackmd.js getting the script path from manifest.json
-  // app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
-  // app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
-  // app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.integrate);
-  // app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.discard);
-  // app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.saveOnHackmd);
+  app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
+  app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
+  app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.integrate);
+  app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.discard);
+  app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.saveOnHackmd);
 
   app.use('/forgot-password', express.Router()
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))

+ 1 - 1
packages/app/src/server/routes/page.js

@@ -1090,7 +1090,7 @@ module.exports = function(crowi, app) {
       target: page,
       action: SupportedAction.ACTION_PAGE_UPDATE,
     };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+    activityEvent.emit('update', res.locals.activity._id, parameters, { path: page.path, creator: page.creator._id.toString() });
   };
 
   /**

+ 11 - 9
packages/app/src/services/renderer/rehype-plugins/keyword-highlighter.ts

@@ -10,7 +10,7 @@ import { Plugin } from 'unified';
  * @param value
  * @returns
  */
-function splitWithKeyword(keyword: string, value: string): string[] {
+function splitWithKeyword(lowercasedKeyword: string, value: string): string[] {
   if (value.length === 0) {
     return [];
   }
@@ -21,7 +21,7 @@ function splitWithKeyword(keyword: string, value: string): string[] {
   const splitted: string[] = [];
 
   do {
-    cursorEnd = value.indexOf(keyword, cursorStart);
+    cursorEnd = value.toLowerCase().indexOf(lowercasedKeyword, cursorStart);
 
     // not found
     if (cursorEnd === -1) {
@@ -29,7 +29,7 @@ function splitWithKeyword(keyword: string, value: string): string[] {
     }
     // keyword is found
     else if (cursorEnd === cursorStart) {
-      cursorEnd += keyword.length;
+      cursorEnd += lowercasedKeyword.length;
     }
 
     splitted.push(value.slice(cursorStart, cursorEnd));
@@ -50,17 +50,17 @@ function wrapWithEm(textElement: Text): Element {
   };
 }
 
-function highlight(keyword: string, node: Text, index: number, parent: Root | Element): void {
-  if (node.value.includes(keyword)) {
-    const splitted = splitWithKeyword(keyword, node.value);
+function highlight(lowercasedKeyword: string, node: Text, index: number, parent: Root | Element): void {
+  if (node.value.toLowerCase().includes(lowercasedKeyword)) {
+    const splitted = splitWithKeyword(lowercasedKeyword, node.value);
 
     parent.children[index] = {
       type: 'element',
       tagName: 'span',
       properties: {},
       children: splitted.map((text) => {
-        return text === keyword
-          ? wrapWithEm({ type: 'text', value: keyword })
+        return text.toLowerCase() === lowercasedKeyword
+          ? wrapWithEm({ type: 'text', value: text })
           : { type: 'text', value: text };
       }),
     };
@@ -79,11 +79,13 @@ export const rehypePlugin: Plugin<[KeywordHighlighterPluginParams]> = (options)
 
   const keywords = (typeof options.keywords === 'string') ? [options.keywords] : options.keywords;
 
+  const lowercasedKeywords = keywords.map(keyword => keyword.toLowerCase());
+
   // 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));
+        lowercasedKeywords.forEach(keyword => highlight(keyword, node, index, parent));
       }
     },
   });

+ 14 - 14
packages/app/test/cypress/integration/30-search/search.spec.ts

@@ -16,7 +16,7 @@ context('Access to search result page', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // for avoid mismatch by auto scrolling
     cy.get('.search-result-content-body-container').scrollTo('top');
     cy.screenshot(`${ssPrefix}with-q`);
@@ -28,7 +28,7 @@ context('Access to search result page', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // for avoid mismatch by auto scrolling
     cy.get('.search-result-content-body-container').scrollTo('top');
 
@@ -105,7 +105,7 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     // for avoid mismatch by auto scrolling
@@ -116,7 +116,7 @@ context('Search all pages', () => {
 
     cy.getByTestid('open-page-item-control-btn').eq(1).click();
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // for avoid mismatch by auto scrolling
     cy.get('.search-result-content-body-container').scrollTo('top');
     cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});
@@ -168,7 +168,7 @@ context('Search all pages', () => {
     });
 
     cy.visit('/');
-    cy.get('.rbt-input').click();
+    cy.get('.rbt-input').click({force: true});
     cy.get('.rbt-input-main').type(`${searchText}`);
     cy.screenshot(`${ssPrefix}1-insert-search-text-with-tag`, { capture: 'viewport'});
     cy.get('.rbt-input-main').type('{enter}');
@@ -176,14 +176,14 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     cy.screenshot(`${ssPrefix}2-search-with-tag-result`, {capture: 'viewport'});
 
     cy.getByTestid('open-page-item-control-btn').first().click();
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}3-click-three-dots-menu-search-with-tag`, {capture: 'viewport'});
 
   });
@@ -192,11 +192,11 @@ context('Search all pages', () => {
     const tag = 'help';
 
     cy.visit('/');
-    cy.get('.grw-taglabels-container > form > a').contains(tag).click();
+    cy.get('.grw-taglabels-container > div > a').contains(tag).click();
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     cy.screenshot(`${ssPrefix}1-tag-order-click-tag-name`, {capture: 'viewport'});
@@ -209,7 +209,7 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}2-tag-order-by-relevance`);
 
     cy.get('.grw-search-page-nav').within(() => {
@@ -220,7 +220,7 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}3-tag-order-by-creation-date`);
 
     cy.get('.grw-search-page-nav').within(() => {
@@ -231,7 +231,7 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}4-tag-order-by-last-update-date`);
   });
 
@@ -265,7 +265,7 @@ context('Search current tree with "prefix":', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     // for avoid mismatch by auto scrolling
@@ -276,7 +276,7 @@ context('Search current tree with "prefix":', () => {
 
     cy.getByTestid('open-page-item-control-btn').first().click();
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // for avoid mismatch by auto scrolling
     cy.get('.search-result-content-body-container').scrollTo('top');
     cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "6.0.0-RC.3",
+  "version": "6.0.0-RC.7",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "6.0.0-RC.3",
+  "version": "6.0.0-RC.7",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 0
packages/hackmd/.gitignore

@@ -0,0 +1 @@
+/dist

+ 16 - 0
packages/hackmd/package.json

@@ -0,0 +1,16 @@
+{
+  "name": "@growi/hackmd",
+  "version": "6.0.0-RC.7",
+  "description": "GROWI js and css files to use hackmd",
+  "license": "MIT",
+  "main": "dist/index.js",
+  "scripts": {
+    "build": "vite build && npx -y shx cp ./src/index.js ./dist"
+  },
+  "dependencies": {},
+  "devDependencies": {
+    "penpal": "^4.0.0",
+    "throttle-debounce": "^3.0.1",
+    "vite": "^3.1.0"
+  }
+}

+ 1 - 1
packages/app/src/client/hackmd-agent.js → packages/hackmd/src/hackmd-agent.js

@@ -16,7 +16,7 @@ const DEBUG_PENPAL = false;
 
 /* eslint-disable no-console  */
 
-const allowedOrigin = '{{origin}}'; // will be replaced by swig
+const allowedOrigin = '<%= origin %>'; // will be replaced by ejs
 
 
 /**

+ 1 - 1
packages/app/src/client/hackmd-styles.js → packages/hackmd/src/hackmd-styles.js

@@ -12,7 +12,7 @@
 
 /* eslint-disable no-console  */
 
-const styles = '{{styles}}'; // will be replaced by swig
+const styles = '<%= styles %>'; // will be replaced by ejs
 
 /**
  * Insert link tag to load style file

+ 15 - 0
packages/hackmd/src/index.js

@@ -0,0 +1,15 @@
+const fs = require('fs');
+const path = require('path');
+
+const isProduction = process.env.NODE_ENV === 'production';
+const dirPath = isProduction ? '.' : '../dist';
+const stylesJSFile = fs.readFileSync(path.resolve(__dirname, `${dirPath}/styles.js`));
+const agentJSFile = fs.readFileSync(path.resolve(__dirname, `${dirPath}/agent.js`));
+const stylesCSSFile = fs.readFileSync(path.resolve(__dirname, `${dirPath}/styles.css`));
+
+// export to app as string
+module.exports = {
+  stylesJS: stylesJSFile.toString(),
+  agentJS: agentJSFile.toString(),
+  stylesCSS: stylesCSSFile.toString(),
+};

+ 0 - 0
packages/app/src/styles-hackmd/style.scss → packages/hackmd/src/styles.scss


+ 20 - 0
packages/hackmd/vite.config.js

@@ -0,0 +1,20 @@
+import { resolve } from 'path';
+
+import { defineConfig } from 'vite';
+
+
+export default defineConfig({
+  build: {
+    rollupOptions: {
+      input: {
+        styles: resolve(__dirname, 'src/hackmd-styles.js'),
+        agent: resolve(__dirname, 'src/hackmd-agent.js'),
+        stylesCSS: resolve(__dirname, 'src/styles.scss'),
+      },
+      output: {
+        entryFileNames: '[name].js',
+        assetFileNames: '[name].css',
+      },
+    },
+  },
+});

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "6.0.0-RC.3",
+  "version": "6.0.0-RC.7",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 4 - 4
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "6.0.0-RC.3",
+  "version": "6.0.0-RC.7",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
@@ -23,9 +23,9 @@
     "test": ""
   },
   "dependencies": {
-    "@growi/core": "^6.0.0-RC.3",
-    "@growi/remark-growi-plugin": "^6.0.0-RC.3",
-    "@growi/ui": "^6.0.0-RC.3"
+    "@growi/core": "^6.0.0-RC.7",
+    "@growi/remark-growi-plugin": "^6.0.0-RC.7",
+    "@growi/ui": "^6.0.0-RC.7"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

+ 1 - 1
packages/remark-growi-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-growi-plugin",
-  "version": "6.0.0-RC.3",
+  "version": "6.0.0-RC.7",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "6.0.0-RC.3",
+  "version": "6.0.0-RC.7",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "6.0.0-RC.3",
+  "version": "6.0.0-RC.7",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -26,7 +26,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^6.0.0-RC.3",
+    "@growi/slack": "^6.0.0-RC.7",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 2 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "6.0.0-RC.3",
+  "version": "6.0.0-RC.7",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": ["growi"],
@@ -17,7 +17,7 @@
     "test": "jest --verbose"
   },
   "dependencies": {
-    "@growi/core": "^6.0.0-RC.3"
+    "@growi/core": "^6.0.0-RC.7"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

+ 200 - 5
yarn.lock

@@ -1719,6 +1719,16 @@
     ms "^2.1.3"
     secure-json-parse "^2.4.0"
 
+"@esbuild/android-arm@0.15.10":
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.10.tgz#a5f9432eb221afc243c321058ef25fe899886892"
+  integrity sha512-FNONeQPy/ox+5NBkcSbYJxoXj9GWu8gVGJTVmUyoOCKQFDTrHVKgNSzChdNt0I8Aj/iKcsDf2r9BFwv+FSNUXg==
+
+"@esbuild/linux-loong64@0.15.10":
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.10.tgz#78a42897c2cf8db9fd5f1811f7590393b77774c7"
+  integrity sha512-w0Ou3Z83LOYEkwaui2M8VwIp+nLi/NA60lBLMvaJ+vXVMcsARYdEzLNE7RSm4+lSg4zq4d7fAVuzk7PNQ5JFgg==
+
 "@eslint/eslintrc@^1.0.4", "@eslint/eslintrc@^1.3.0":
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f"
@@ -4480,6 +4490,13 @@
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
   integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
 
+"@types/react-scroll@^1.8.4":
+  version "1.8.4"
+  resolved "https://registry.yarnpkg.com/@types/react-scroll/-/react-scroll-1.8.4.tgz#2b6258fb34104d3fcc7a22e8eceaadc669ba3ad1"
+  integrity sha512-DpHA9PYw42/rBrfKbGE/kAEvHRfyDL/ACfKB/ORWUYuCLi/yGrntxSzYXmg/7TLgQsJ5ma13GCDOzFSOz+8XOA==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react@*":
   version "16.9.23"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.23.tgz#1a66c6d468ba11a8943ad958a8cb3e737568271c"
@@ -5495,6 +5512,11 @@ async@^2.0.1, async@^2.6.2:
   dependencies:
     lodash "^4.17.14"
 
+async@^3.2.3:
+  version "3.2.4"
+  resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c"
+  integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==
+
 async@~0.2.6:
   version "0.2.10"
   resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
@@ -6589,7 +6611,7 @@ chalk@4.1.0, chalk@^4.0.0, chalk@^4.1.0:
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
 
-chalk@4.1.2, chalk@^4.1.1:
+chalk@4.1.2, chalk@^4.0.2, chalk@^4.1.1:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
   integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -8514,6 +8536,13 @@ ejs@^3.0.0, ejs@^3.1.5:
   dependencies:
     jake "^10.6.1"
 
+ejs@^3.1.8:
+  version "3.1.8"
+  resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.8.tgz#758d32910c78047585c7ef1f92f9ee041c1c190b"
+  integrity sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==
+  dependencies:
+    jake "^10.8.5"
+
 electron-to-chromium@^1.3.723:
   version "1.3.792"
   resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.792.tgz#791b0d8fcf7411885d086193fb49aaef0c1594ca"
@@ -8993,6 +9022,134 @@ esa-node@^0.2.2:
   dependencies:
     axios "^0.18.0"
 
+esbuild-android-64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.15.10.tgz#8a59a84acbf2eca96996cadc35642cf055c494f0"
+  integrity sha512-UI7krF8OYO1N7JYTgLT9ML5j4+45ra3amLZKx7LO3lmLt1Ibn8t3aZbX5Pu4BjWiqDuJ3m/hsvhPhK/5Y/YpnA==
+
+esbuild-android-arm64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.10.tgz#f453851dc1d8c5409a38cf7613a33852faf4915d"
+  integrity sha512-EOt55D6xBk5O05AK8brXUbZmoFj4chM8u3riGflLa6ziEoVvNjRdD7Cnp82NHQGfSHgYR06XsPI8/sMuA/cUwg==
+
+esbuild-darwin-64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.10.tgz#778bd29c8186ff47b176c8af58c08cf0fb8e6b86"
+  integrity sha512-hbDJugTicqIm+WKZgp208d7FcXcaK8j2c0l+fqSJ3d2AzQAfjEYDRM3Z2oMeqSJ9uFxyj/muSACLdix7oTstRA==
+
+esbuild-darwin-arm64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.10.tgz#b30bbefb46dc3c5d4708b0435e52f6456578d6df"
+  integrity sha512-M1t5+Kj4IgSbYmunf2BB6EKLkWUq+XlqaFRiGOk8bmBapu9bCDrxjf4kUnWn59Dka3I27EiuHBKd1rSO4osLFQ==
+
+esbuild-freebsd-64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.10.tgz#ab301c5f6ded5110dbdd611140bef1a7c2e99236"
+  integrity sha512-KMBFMa7C8oc97nqDdoZwtDBX7gfpolkk6Bcmj6YFMrtCMVgoU/x2DI1p74DmYl7CSS6Ppa3xgemrLrr5IjIn0w==
+
+esbuild-freebsd-arm64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.10.tgz#a5b09b867a6ff49110f52343b6f12265db63d43f"
+  integrity sha512-m2KNbuCX13yQqLlbSojFMHpewbn8wW5uDS6DxRpmaZKzyq8Dbsku6hHvh2U+BcLwWY4mpgXzFUoENEf7IcioGg==
+
+esbuild-linux-32@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.15.10.tgz#5282fe9915641caf9c8070e4ba2c3e16d358f837"
+  integrity sha512-guXrwSYFAvNkuQ39FNeV4sNkNms1bLlA5vF1H0cazZBOLdLFIny6BhT+TUbK/hdByMQhtWQ5jI9VAmPKbVPu1w==
+
+esbuild-linux-64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.15.10.tgz#f3726e85a00149580cb19f8abfabcbb96f5d52bb"
+  integrity sha512-jd8XfaSJeucMpD63YNMO1JCrdJhckHWcMv6O233bL4l6ogQKQOxBYSRP/XLWP+6kVTu0obXovuckJDcA0DKtQA==
+
+esbuild-linux-arm64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.10.tgz#2f0056e9d5286edb0185b56655caa8c574d8dbe7"
+  integrity sha512-GByBi4fgkvZFTHFDYNftu1DQ1GzR23jws0oWyCfhnI7eMOe+wgwWrc78dbNk709Ivdr/evefm2PJiUBMiusS1A==
+
+esbuild-linux-arm@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.10.tgz#40a9270da3c8ffa32cf72e24a79883e323dff08d"
+  integrity sha512-6N8vThLL/Lysy9y4Ex8XoLQAlbZKUyExCWyayGi2KgTBelKpPgj6RZnUaKri0dHNPGgReJriKVU6+KDGQwn10A==
+
+esbuild-linux-mips64le@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.10.tgz#90ce1c4ee0202edb4ac69807dea77f7e5804abc4"
+  integrity sha512-BxP+LbaGVGIdQNJUNF7qpYjEGWb0YyHVSKqYKrn+pTwH/SiHUxFyJYSP3pqkku61olQiSBnSmWZ+YUpj78Tw7Q==
+
+esbuild-linux-ppc64le@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.10.tgz#782837ae7bd5b279178106c9dd801755a21fabdf"
+  integrity sha512-LoSQCd6498PmninNgqd/BR7z3Bsk/mabImBWuQ4wQgmQEeanzWd5BQU2aNi9mBURCLgyheuZS6Xhrw5luw3OkQ==
+
+esbuild-linux-riscv64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.10.tgz#d7420d806ece5174f24f4634303146f915ab4207"
+  integrity sha512-Lrl9Cr2YROvPV4wmZ1/g48httE8z/5SCiXIyebiB5N8VT7pX3t6meI7TQVHw/wQpqP/AF4SksDuFImPTM7Z32Q==
+
+esbuild-linux-s390x@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.10.tgz#21fdf0cb3494a7fb520a71934e4dffce67fe47be"
+  integrity sha512-ReP+6q3eLVVP2lpRrvl5EodKX7EZ1bS1/z5j6hsluAlZP5aHhk6ghT6Cq3IANvvDdscMMCB4QEbI+AjtvoOFpA==
+
+esbuild-netbsd-64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.10.tgz#6c06b3107e3df53de381e6299184d4597db0440f"
+  integrity sha512-iGDYtJCMCqldMskQ4eIV+QSS/CuT7xyy9i2/FjpKvxAuCzrESZXiA1L64YNj6/afuzfBe9i8m/uDkFHy257hTw==
+
+esbuild-openbsd-64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.10.tgz#4daef5f5d8e74bbda53b65160029445d582570cf"
+  integrity sha512-ftMMIwHWrnrYnvuJQRJs/Smlcb28F9ICGde/P3FUTCgDDM0N7WA0o9uOR38f5Xe2/OhNCgkjNeb7QeaE3cyWkQ==
+
+esbuild-sunos-64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.10.tgz#5fe7bef267a02f322fd249a8214d0274937388a7"
+  integrity sha512-mf7hBL9Uo2gcy2r3rUFMjVpTaGpFJJE5QTDDqUFf1632FxteYANffDZmKbqX0PfeQ2XjUDE604IcE7OJeoHiyg==
+
+esbuild-windows-32@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.15.10.tgz#48e3dde25ab0135579a288b30ab6ddef6d1f0b28"
+  integrity sha512-ttFVo+Cg8b5+qHmZHbEc8Vl17kCleHhLzgT8X04y8zudEApo0PxPg9Mz8Z2cKH1bCYlve1XL8LkyXGFjtUYeGg==
+
+esbuild-windows-64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.15.10.tgz#387a9515bef3fee502d277a5d0a2db49a4ecda05"
+  integrity sha512-2H0gdsyHi5x+8lbng3hLbxDWR7mKHWh5BXZGKVG830KUmXOOWFE2YKJ4tHRkejRduOGDrBvHBriYsGtmTv3ntA==
+
+esbuild-windows-arm64@0.15.10:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.10.tgz#5a6fcf2fa49e895949bf5495cf088ab1b43ae879"
+  integrity sha512-S+th4F+F8VLsHLR0zrUcG+Et4hx0RKgK1eyHc08kztmLOES8BWwMiaGdoW9hiXuzznXQ0I/Fg904MNbr11Nktw==
+
+esbuild@^0.15.9:
+  version "0.15.10"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.10.tgz#85c2f8446e9b1fe04fae68daceacba033eedbd42"
+  integrity sha512-N7wBhfJ/E5fzn/SpNgX+oW2RLRjwaL8Y0ezqNqhjD6w0H2p0rDuEz2FKZqpqLnO8DCaWumKe8dsC/ljvVSSxng==
+  optionalDependencies:
+    "@esbuild/android-arm" "0.15.10"
+    "@esbuild/linux-loong64" "0.15.10"
+    esbuild-android-64 "0.15.10"
+    esbuild-android-arm64 "0.15.10"
+    esbuild-darwin-64 "0.15.10"
+    esbuild-darwin-arm64 "0.15.10"
+    esbuild-freebsd-64 "0.15.10"
+    esbuild-freebsd-arm64 "0.15.10"
+    esbuild-linux-32 "0.15.10"
+    esbuild-linux-64 "0.15.10"
+    esbuild-linux-arm "0.15.10"
+    esbuild-linux-arm64 "0.15.10"
+    esbuild-linux-mips64le "0.15.10"
+    esbuild-linux-ppc64le "0.15.10"
+    esbuild-linux-riscv64 "0.15.10"
+    esbuild-linux-s390x "0.15.10"
+    esbuild-netbsd-64 "0.15.10"
+    esbuild-openbsd-64 "0.15.10"
+    esbuild-sunos-64 "0.15.10"
+    esbuild-windows-32 "0.15.10"
+    esbuild-windows-64 "0.15.10"
+    esbuild-windows-arm64 "0.15.10"
+
 escalade@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
@@ -12980,6 +13137,16 @@ jake@^10.6.1:
     filelist "^1.0.1"
     minimatch "^3.0.4"
 
+jake@^10.8.5:
+  version "10.8.5"
+  resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46"
+  integrity sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==
+  dependencies:
+    async "^3.2.3"
+    chalk "^4.0.2"
+    filelist "^1.0.1"
+    minimatch "^3.0.4"
+
 jest-changed-files@^28.1.3:
   version "28.1.3"
   resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-28.1.3.tgz#d9aeee6792be3686c47cb988a8eaf82ff4238831"
@@ -17853,6 +18020,15 @@ postcss@^8.3.11:
     picocolors "^1.0.0"
     source-map-js "^0.6.2"
 
+postcss@^8.4.16:
+  version "8.4.17"
+  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.17.tgz#f87863ec7cd353f81f7ab2dec5d67d861bbb1be5"
+  integrity sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==
+  dependencies:
+    nanoid "^3.3.4"
+    picocolors "^1.0.0"
+    source-map-js "^1.0.2"
+
 postcss@^8.4.5:
   version "8.4.5"
   resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.5.tgz#bae665764dfd4c6fcc24dc0fdf7e7aa00cc77f95"
@@ -18171,9 +18347,9 @@ qs@6.7.0:
   integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
 
 qs@^6.10.2:
-  version "6.10.2"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.2.tgz#c1431bea37fc5b24c5bdbafa20f16bdf2a4b9ffe"
-  integrity sha512-mSIdjzqznWgfd4pMii7sHtaYF8rx8861hBO80SraY5GT0XQibWZWJSid0avzHGkDIZLImux2S5mXO0Hfct2QCw==
+  version "6.11.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
+  integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
   dependencies:
     side-channel "^1.0.4"
 
@@ -20479,7 +20655,7 @@ resolve@^1.10.0:
   dependencies:
     path-parse "^1.0.6"
 
-resolve@^1.10.1:
+resolve@^1.10.1, resolve@^1.22.1:
   version "1.22.1"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
   integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
@@ -20725,6 +20901,13 @@ rndm@1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/rndm/-/rndm-1.2.0.tgz#f33fe9cfb52bbfd520aa18323bc65db110a1b76c"
 
+rollup@~2.78.0:
+  version "2.78.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.78.1.tgz#52fe3934d9c83cb4f7c4cb5fb75d88591be8648f"
+  integrity sha512-VeeCgtGi4P+o9hIg+xz4qQpRl6R401LWEXBmxYKOV4zlF82lyhgh2hTZnheFUbANE8l2A41F458iwj2vEYaXJg==
+  optionalDependencies:
+    fsevents "~2.3.2"
+
 run-async@^2.4.0:
   version "2.4.1"
   resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
@@ -24285,6 +24468,18 @@ vfile@^5.0.0, vfile@^5.1.0:
     unist-util-stringify-position "^3.0.0"
     vfile-message "^3.0.0"
 
+vite@^3.1.0:
+  version "3.1.7"
+  resolved "https://registry.yarnpkg.com/vite/-/vite-3.1.7.tgz#9fc2b57a395f79175d38fa3cffd15080b0d9cbfc"
+  integrity sha512-5vCAmU4S8lyVdFCInu9M54f/g8qbOMakVw5xJ4pjoaDy5wgy9sLLZkGdSLN52dlsBqh0tBqxjaqqa8LgPqwRAA==
+  dependencies:
+    esbuild "^0.15.9"
+    postcss "^8.4.16"
+    resolve "^1.22.1"
+    rollup "~2.78.0"
+  optionalDependencies:
+    fsevents "~2.3.2"
+
 void-elements@3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"