Forráskód Böngészése

Merge remote-tracking branch 'origin/master' into imprv/vrt

Yuki Takei 3 éve
szülő
commit
03f0ebd204
39 módosított fájl, 579 hozzáadás és 363 törlés
  1. 1 4
      packages/app/src/client/services/AdminMarkDownContainer.js
  2. 12 5
      packages/app/src/client/services/page-operation.ts
  3. 9 8
      packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  4. 1 1
      packages/app/src/components/Layout/ShareLinkLayout.tsx
  5. 2 2
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  6. 28 39
      packages/app/src/components/PageEditor.tsx
  7. 0 112
      packages/app/src/components/PageEditor/Cheatsheet.jsx
  8. 114 0
      packages/app/src/components/PageEditor/Cheatsheet.tsx
  9. 0 8
      packages/app/src/components/PageEditor/Editor.module.scss
  10. 2 2
      packages/app/src/components/PageEditor/Editor.tsx
  11. 3 2
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  12. 38 41
      packages/app/src/components/PageEditorByHackmd.tsx
  13. 5 3
      packages/app/src/components/PageStatusAlert.tsx
  14. 10 5
      packages/app/src/components/SavePageControls.tsx
  15. 4 2
      packages/app/src/components/Sidebar.tsx
  16. 12 0
      packages/app/src/components/Sidebar/CustomSidebar.module.scss
  17. 11 15
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  18. 22 20
      packages/app/src/components/Sidebar/PageTree.tsx
  19. 14 1
      packages/app/src/components/Sidebar/PageTree/ItemsTree.module.scss
  20. 4 2
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  21. 17 0
      packages/app/src/components/Sidebar/RecentChanges.module.scss
  22. 43 67
      packages/app/src/components/Sidebar/RecentChanges.tsx
  23. 14 0
      packages/app/src/components/Sidebar/SidebarHeaderReloadButton.tsx
  24. 18 0
      packages/app/src/components/Sidebar/Skeleton/CustomSidebarContentSkeleton.tsx
  25. 18 0
      packages/app/src/components/Sidebar/Skeleton/PageTreeContentSkeleton.tsx
  26. 40 0
      packages/app/src/components/Sidebar/Skeleton/RecentChangesContentSkeleton.tsx
  27. 6 0
      packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.module.scss
  28. 50 0
      packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.tsx
  29. 23 0
      packages/app/src/components/Sidebar/Skeleton/TagContentSkeleton.tsx
  30. 10 0
      packages/app/src/components/Sidebar/Tag.module.scss
  31. 5 10
      packages/app/src/components/Sidebar/Tag.tsx
  32. 1 1
      packages/app/src/components/Skeleton.tsx
  33. 5 0
      packages/app/src/interfaces/rehype.ts
  34. 3 1
      packages/app/src/interfaces/services/renderer.ts
  35. 4 2
      packages/app/src/pages/[[...path]].page.tsx
  36. 0 2
      packages/app/src/server/models/config.ts
  37. 8 8
      packages/app/src/server/routes/apiv3/markdown-setting.js
  38. 1 0
      packages/app/src/server/routes/index.js
  39. 21 0
      packages/app/src/styles/_mixins.scss

+ 1 - 4
packages/app/src/client/services/AdminMarkDownContainer.js

@@ -27,7 +27,7 @@ export default class AdminMarkDownContainer extends Container {
       pageBreakSeparator: 1,
       pageBreakSeparator: 1,
       pageBreakCustomSeparator: '',
       pageBreakCustomSeparator: '',
       isEnabledXss: false,
       isEnabledXss: false,
-      xssOption: 1,
+      xssOption: '',
       tagWhiteList: '',
       tagWhiteList: '',
       attrWhiteList: '',
       attrWhiteList: '',
     };
     };
@@ -86,9 +86,6 @@ export default class AdminMarkDownContainer extends Container {
    * Switch enableXss
    * Switch enableXss
    */
    */
   switchEnableXss() {
   switchEnableXss() {
-    if (this.state.isEnabledXss) {
-      this.setState({ xssOption: null });
-    }
     this.setState({ isEnabledXss: !this.state.isEnabledXss });
     this.setState({ isEnabledXss: !this.state.isEnabledXss });
   }
   }
 
 

+ 12 - 5
packages/app/src/client/services/page-operation.ts

@@ -3,8 +3,8 @@ import urljoin from 'url-join';
 
 
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { useCurrentPageId } from '~/stores/context';
 import { useCurrentPageId } from '~/stores/context';
-import { useIsEnabledUnsavedWarning } from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -175,16 +175,23 @@ export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
   };
   };
 };
 };
 
 
-export const useUpdateStateAfterSave = () => {
+export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => Promise<void>) | undefined => {
   const { mutate: mutateCurrentPageId } = useCurrentPageId();
   const { mutate: mutateCurrentPageId } = useCurrentPageId();
   const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+  const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
+  const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
+
+  if (pageId == null) { return }
 
 
   // update swr 'currentPageId', 'currentPage', remote states
   // update swr 'currentPageId', 'currentPage', remote states
-  return async(pageId: string) => {
+  return async() => {
     await mutateCurrentPageId(pageId);
     await mutateCurrentPageId(pageId);
     const updatedPage = await mutateCurrentPage();
     const updatedPage = await mutateCurrentPage();
 
 
+    await mutateTagsInfo(); // get from DB
+    syncTagsInfoForEditor(); // sync global state for client
+
     if (updatedPage == null) { return }
     if (updatedPage == null) { return }
 
 
     const remoterevisionData = {
     const remoterevisionData = {
@@ -192,7 +199,7 @@ export const useUpdateStateAfterSave = () => {
       remoteRevisionBody: updatedPage.revision.body,
       remoteRevisionBody: updatedPage.revision.body,
       remoteRevisionLastUpdateUser: updatedPage.lastUpdateUser,
       remoteRevisionLastUpdateUser: updatedPage.lastUpdateUser,
       remoteRevisionLastUpdatedAt: updatedPage.updatedAt,
       remoteRevisionLastUpdatedAt: updatedPage.updatedAt,
-      revisionIdHackmdSynced: updatedPage.revisionHackmdSynced.toString(),
+      revisionIdHackmdSynced: updatedPage.revisionHackmdSynced?.toString(),
       hasDraftOnHackmd: updatedPage.hasDraftOnHackmd,
       hasDraftOnHackmd: updatedPage.hasDraftOnHackmd,
     };
     };
 
 

+ 9 - 8
packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
 
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import { tags, attrs } from '~/services/xss/recommended-whitelist';
 import { tags, attrs } from '~/services/xss/recommended-whitelist';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -49,12 +50,12 @@ class XssForm extends React.Component {
               <input
               <input
                 type="radio"
                 type="radio"
                 className="custom-control-input"
                 className="custom-control-input"
-                id="xssOption2"
+                id="xssOption1"
                 name="XssOption"
                 name="XssOption"
-                checked={xssOption === 2}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: 2 }) }}
+                checked={xssOption === RehypeSanitizeOption.RECOMMENDED}
+                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeOption.RECOMMENDED }) }}
               />
               />
-              <label className="custom-control-label w-100" htmlFor="xssOption2">
+              <label className="custom-control-label w-100" htmlFor="xssOption1">
                 <p className="font-weight-bold">{t('markdown_settings.xss_options.recommended_setting')}</p>
                 <p className="font-weight-bold">{t('markdown_settings.xss_options.recommended_setting')}</p>
                 <div className="mt-4">
                 <div className="mt-4">
                   <div className="d-flex justify-content-between">
                   <div className="d-flex justify-content-between">
@@ -91,12 +92,12 @@ class XssForm extends React.Component {
               <input
               <input
                 type="radio"
                 type="radio"
                 className="custom-control-input"
                 className="custom-control-input"
-                id="xssOption3"
+                id="xssOption2"
                 name="XssOption"
                 name="XssOption"
-                checked={xssOption === 3}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: 3 }) }}
+                checked={xssOption === RehypeSanitizeOption.CUSTOM}
+                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeOption.CUSTOM }) }}
               />
               />
-              <label className="custom-control-label w-100" htmlFor="xssOption3">
+              <label className="custom-control-label w-100" htmlFor="xssOption2">
                 <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
                 <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
                 <WhiteListInput customizable />
                 <WhiteListInput customizable />
               </label>
               </label>

+ 1 - 1
packages/app/src/components/Layout/ShareLinkLayout.tsx

@@ -30,7 +30,7 @@ export const ShareLinkLayout = ({
 
 
   return (
   return (
     <RawLayout title={title} className={myClassName}>
     <RawLayout title={title} className={myClassName}>
-      <GrowiNavbar />
+      <GrowiNavbar isGlobalSearchHidden={true} />
 
 
       <div className="page-wrapper d-flex d-print-block">
       <div className="page-wrapper d-flex d-print-block">
         <div className="flex-fill mw-0" style={{ position: 'relative' }}>
         <div className="flex-fill mw-0" style={{ position: 'relative' }}>

+ 2 - 2
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -188,7 +188,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
 
   const router = useRouter();
   const router = useRouter();
 
 
-  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { data: shareLinkId } = useShareLinkId();
+  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
 
   const revision = currentPage?.revision;
   const revision = currentPage?.revision;
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
@@ -201,7 +202,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isNotFound } = useIsNotFound();
   const { data: isNotFound } = useIsNotFound();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
-  const { data: shareLinkId } = useShareLinkId();
   const { data: isContainerFluid } = useIsContainerFluid();
   const { data: isContainerFluid } = useIsContainerFluid();
 
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();

+ 28 - 39
packages/app/src/components/PageEditor.tsx

@@ -69,7 +69,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const router = useRouter();
   const router = useRouter();
 
 
   const { data: isNotFound } = useIsNotFound();
   const { data: isNotFound } = useIsNotFound();
-  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: pageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
@@ -82,7 +82,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
@@ -94,7 +93,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const saveOrUpdate = useSaveOrUpdate();
   const saveOrUpdate = useSaveOrUpdate();
 
 
-  const updateStateAfterSave = useUpdateStateAfterSave();
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
 
 
   const currentRevisionId = currentPage?.revision?._id;
   const currentRevisionId = currentPage?.revision?._id;
 
 
@@ -117,8 +116,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const markdownToSave = useRef<string>(initialValue);
   const markdownToSave = useRef<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
 
-  const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
-
   const { data: socket } = useGlobalSocket();
   const { data: socket } = useGlobalSocket();
 
 
   const { mutate: mutateIsConflict } = useIsConflict();
   const { mutate: mutateIsConflict } = useIsConflict();
@@ -152,18 +149,21 @@ const PageEditor = React.memo((): JSX.Element => {
 
 
   }, [socket, checkIsConflict]);
   }, [socket, checkIsConflict]);
 
 
-  // const optionsToSave = useMemo(() => {
-  //   if (grantData == null) {
-  //     return;
-  //   }
-  //   const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
-  //   const optionsToSave = getOptionsToSave(
-  //     isSlackEnabled ?? false, slackChannels,
-  //     grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
-  //     pageTags || [],
-  //   );
-  //   return optionsToSave;
-  // }, [grantData, isSlackEnabled, pageTags, slackChannelsData]);
+  const optionsToSave = useMemo((): OptionsToSave | undefined => {
+    if (grantData == null) {
+      return;
+    }
+    const optionsToSave = {
+      isSlackEnabled: isSlackEnabled ?? false,
+      slackChannels: '', // set in save method by opts in SavePageControlls.tsx
+      grant: grantData.grant,
+      pageTags: pageTags ?? [],
+      grantUserGroupId: grantData.grantedGroup?.id,
+      grantUserGroupName: grantData.grantedGroup?.name,
+    };
+    return optionsToSave;
+  }, [grantData, isSlackEnabled, pageTags]);
+
   // register to facade
   // register to facade
   useEffect(() => {
   useEffect(() => {
     // for markdownRenderer
     // for markdownRenderer
@@ -189,30 +189,19 @@ const PageEditor = React.memo((): JSX.Element => {
     setMarkdownWithDebounce(value, isClean);
     setMarkdownWithDebounce(value, isClean);
   }, [setMarkdownWithDebounce]);
   }, [setMarkdownWithDebounce]);
 
 
-  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
-    if (grantData == null || isSlackEnabled == null || currentPathname == null) {
+  const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
+    if (currentPathname == null || optionsToSave == null) {
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       throw new Error('Some materials to save are invalid');
       throw new Error('Some materials to save are invalid');
     }
     }
 
 
-    const grant = grantData.grant || PageGrant.GRANT_PUBLIC;
-    const grantedGroup = grantData?.grantedGroup;
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled,
-      slackChannels,
-      grant: grant || 1,
-      pageTags: pageTags || [],
-      grantUserGroupId: grantedGroup?.id,
-      grantUserGroupName: grantedGroup?.name,
-      ...opts,
-    };
+    const options = Object.assign(optionsToSave, opts);
 
 
     try {
     try {
       const { page } = await saveOrUpdate(
       const { page } = await saveOrUpdate(
         markdownToSave.current,
         markdownToSave.current,
         { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
         { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
-        optionsToSave,
+        options,
       );
       );
 
 
       return page;
       return page;
@@ -232,9 +221,9 @@ const PageEditor = React.memo((): JSX.Element => {
     }
     }
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
+  }, [currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
 
 
-  const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
+  const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {
       return;
       return;
     }
     }
@@ -248,10 +237,10 @@ const PageEditor = React.memo((): JSX.Element => {
       await router.push(`/${page._id}`);
       await router.push(`/${page._id}`);
     }
     }
     else {
     else {
-      updateStateAfterSave(page._id);
+      updateStateAfterSave?.();
     }
     }
     mutateEditorMode(EditorMode.View);
     mutateEditorMode(EditorMode.View);
-  }, [editorMode, save, isNotFound, mutateEditorMode, router, useUpdateStateAfterSave]);
+  }, [editorMode, save, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
 
 
   const saveWithShortcut = useCallback(async() => {
   const saveWithShortcut = useCallback(async() => {
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {
@@ -260,10 +249,10 @@ const PageEditor = React.memo((): JSX.Element => {
 
 
     const page = await save();
     const page = await save();
     if (page != null) {
     if (page != null) {
-      updateStateAfterSave(page._id);
+      updateStateAfterSave?.();
       toastSuccess(t('toaster.save_succeeded'));
       toastSuccess(t('toaster.save_succeeded'));
     }
     }
-  }, [editorMode, save, t, useUpdateStateAfterSave]);
+  }, [editorMode, save, t, updateStateAfterSave]);
 
 
 
 
   /**
   /**
@@ -540,7 +529,7 @@ const PageEditor = React.memo((): JSX.Element => {
         isOpen={conflictDiffModalStatus?.isOpened}
         isOpen={conflictDiffModalStatus?.isOpened}
         onClose={() => closeConflictDiffModal()}
         onClose={() => closeConflictDiffModal()}
         markdownOnEdit={markdownToPreview}
         markdownOnEdit={markdownToPreview}
-        optionsToSave={undefined} // replace undefined
+        optionsToSave={optionsToSave}
         afterResolvedHandler={afterResolvedHandler}
         afterResolvedHandler={afterResolvedHandler}
       />
       />
     </div>
     </div>

+ 0 - 112
packages/app/src/components/PageEditor/Cheatsheet.jsx

@@ -1,112 +0,0 @@
-/* eslint-disable max-len */
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-class Cheatsheet extends React.Component {
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div className="row small">
-        <div className="col-sm-6">
-          <h4>{t('sandbox.header')}</h4>
-          <ul className="hljs">
-            <li><code># </code>{t('sandbox.header_x', { index: '1' })}</li>
-            <li><code>## </code>{t('sandbox.header_x', { index: '2' })}</li>
-            <li><code>### </code>{t('sandbox.header_x', { index: '3' })}</li>
-          </ul>
-          <h4>{t('sandbox.block')}</h4>
-          <p className="mb-1"><code>[{t('sandbox.empty_line')}]</code>{t('sandbox.block_detail')}</p>
-          <ul className="hljs">
-            <li>text</li>
-            <li></li>
-            <li>text</li>
-          </ul>
-          <h4>{t('sandbox.line_break')}</h4>
-          <p className="mb-1"><code>[ ][ ]</code> {t('sandbox.line_break_detail')}</p>
-          <ul className="hljs">
-            <li>text&nbsp;&nbsp;</li>
-            <li>text</li>
-          </ul>
-          <h4>{t('sandbox.typography')}</h4>
-          <ul className="hljs">
-            <li><i>*{t('sandbox.italics')}*</i></li>
-            <li><b>**{t('sandbox.bold')}**</b></li>
-            <li><i><b>***{t('sandbox.italic_bold')}***</b></i></li>
-            <li>~~{t('sandbox.strikethrough')}~~ =&lt; <s>{t('sandbox.strikethrough')}</s></li>
-          </ul>
-          <h4>{t('sandbox.link')}</h4>
-          <ul className="hljs">
-            <li>[Google](https://www.google.co.jp/)</li>
-            <li>[/Page1/ChildPage1]</li>
-          </ul>
-          <h4>{t('sandbox.code_highlight')}</h4>
-          <ul className="hljs">
-            <li>```javascript:index.js</li>
-            <li>writeCode();</li>
-            <li>```</li>
-          </ul>
-        </div>
-        <div className="col-sm-6">
-          <h4>{t('sandbox.list')}</h4>
-          <ul className="hljs">
-            <li>- {t('sandbox.unordered_list_x', { index: '1' })}</li>
-            <li>&nbsp;&nbsp;- {t('sandbox.unordered_list_x', { index: '1.1' })}</li>
-            <li>- {t('sandbox.unordered_list_x', { index: '2' })}</li>
-          </ul>
-          <ul className="hljs">
-            <li>1. {t('sandbox.ordered_list_x', { index: '1' })}</li>
-            <li>1. {t('sandbox.ordered_list_x', { index: '2' })}</li>
-          </ul>
-          <ul className="hljs">
-            <li>- [ ] {t('sandbox.task')}({t('sandbox.task_unchecked')})</li>
-            <li>- [x] {t('sandbox.task')}({t('sandbox.task_checked')})</li>
-          </ul>
-          <h4>{t('sandbox.quote')}</h4>
-          <ul className="hljs">
-            <li>&gt; {t('sandbox.quote1')}</li>
-            <li>&gt; {t('sandbox.quote2')}</li>
-          </ul>
-          <ul className="hljs">
-            <li>&gt;&gt; {t('sandbox.quote_nested')}</li>
-            <li>&gt;&gt;&gt; {t('sandbox.quote_nested')}</li>
-            <li>&gt;&gt;&gt;&gt; {t('sandbox.quote_nested')}</li>
-          </ul>
-          <h4>{t('sandbox.table')}</h4>
-          <pre className="border-0">
-            |Left&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;Mid&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Right|<br />
-            |:----------|:---------:|----------:|<br />
-            |col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|<br />
-            |col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|<br />
-          </pre>
-          <h4>{t('sandbox.image')}</h4>
-          <p className="mb-1"><code> ![{t('sandbox.alt_text')}](URL)</code> {t('sandbox.insert_image')}</p>
-          <ul className="hljs">
-            <li>![ex](https://example.com/image.png)</li>
-          </ul>
-
-          <hr />
-          <a href="/Sandbox" className="btn btn-info btn-block" target="_blank">
-            <i className="icon-share-alt" /> {t('sandbox.open_sandbox')}
-          </a>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-Cheatsheet.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-const CheatsheetWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <Cheatsheet t={t} {...props} />;
-};
-
-export default CheatsheetWrapperFC;

+ 114 - 0
packages/app/src/components/PageEditor/Cheatsheet.tsx

@@ -0,0 +1,114 @@
+/* eslint-disable max-len */
+
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { PrismAsyncLight } from 'react-syntax-highlighter';
+import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+
+export const Cheatsheet = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  /*
+  * Each Element
+  */
+  // Left Side
+  const codeStr = `# ${t('sandbox.header_x', { index: '1' })}\n## ${t('sandbox.header_x', { index: '2' })}\n### ${t('sandbox.header_x', { index: '3' })}`;
+  const codeBlockStr = 'text\n\ntext';
+  const lineBlockStr = 'text\ntext';
+  const typographyStr = `*${t('sandbox.italics')}*\n**${t('sandbox.bold')}**\n***${t('sandbox.italic_bold')}***\n~~${t('sandbox.strikethrough')}~~`;
+  const linkStr = '[Google](https://www.google.co.jp/)\n[/Page1/ChildPage1]';
+  const codeHighlightStr = '```javascript:index.js\nwriteCode();\n```';
+
+  // Right Side
+  const codeListStr = `- ${t('sandbox.unordered_list_x', { index: '1' })}
+    - ${t('sandbox.unordered_list_x', { index: '1.1' })}
+    - ${t('sandbox.unordered_list_x', { index: '1.2' })}`;
+  const orderedListStr = `1. ${t('sandbox.ordered_list_x', { index: '1' })}\n1. ${t('sandbox.ordered_list_x', { index: '2' })}`;
+  const taskStr = `- [ ] ${t('sandbox.task')}(${t('sandbox.task_unchecked')})\n- [x] ${t('sandbox.task')}(${t('sandbox.task_checked')})`;
+  const quoteStr = `> ${t('sandbox.quote1')}\n> ${t('sandbox.quote2')}`;
+  const nestedQuoteStr = `>> ${t('sandbox.quote_nested')}\n>>> ${t('sandbox.quote_nested')}\n>>>> ${t('sandbox.quote_nested')}`;
+  const tableStr = '|Left       |    Mid    |      Right|\n|:----------|:---------:|----------:|\n|col 1      |   col 2   |      col 3|\n|col 1      |   col 2   |      col 3|';
+  const imageStr = '![ex](https://example.com/image.png)';
+
+
+  const renderCheetSheetElm = (CheetSheetElm: string) => {
+    return (
+      <PrismAsyncLight
+        className="code-highlighted"
+        PreTag="div"
+        style={oneDark}
+        language={'text'}
+      >
+        {String(CheetSheetElm).replace(/\n$/, '')}
+      </PrismAsyncLight>
+    );
+  };
+
+
+  return (
+    <div className="row small">
+      <div className="col-sm-6">
+
+        {/* Header */}
+        <h4>{t('sandbox.header')}</h4>
+        {renderCheetSheetElm(codeStr)}
+
+        {/* Block */}
+        <h4>{t('sandbox.block')}</h4>
+        <p className="mb-1"><code>[{t('sandbox.empty_line')}]</code>{t('sandbox.block_detail')}</p>
+        {renderCheetSheetElm(codeBlockStr)}
+
+        {/* Line Break */}
+        <h4>{t('sandbox.line_break')}</h4>
+        <p className="mb-1"><code>[ ][ ]</code> {t('sandbox.line_break_detail')}</p>
+        {renderCheetSheetElm(lineBlockStr)}
+
+
+        {/* Typography */}
+        <h4>{t('sandbox.typography')}</h4>
+        {renderCheetSheetElm(typographyStr)}
+
+        {/* Link */}
+        <h4>{t('sandbox.link')}</h4>
+        {renderCheetSheetElm(linkStr)}
+
+        {/* CodeHhighlight */}
+        <h4>{t('sandbox.code_highlight')}</h4>
+        {renderCheetSheetElm(codeHighlightStr)}
+      </div>
+
+      <div className="col-sm-6">
+        {/* List */}
+        <h4>{t('sandbox.list')}</h4>
+        {renderCheetSheetElm(codeListStr)}
+
+        {renderCheetSheetElm(orderedListStr)}
+
+        {renderCheetSheetElm(taskStr)}
+
+        {/* Quote */}
+        <h4>{t('sandbox.quote')}</h4>
+        {renderCheetSheetElm(quoteStr)}
+
+        {renderCheetSheetElm(nestedQuoteStr)}
+
+
+        {/* Table */}
+        <h4>{t('sandbox.table')}</h4>
+        {renderCheetSheetElm(tableStr)}
+
+        {/* Image */}
+        <h4>{t('sandbox.image')}</h4>
+        <p className="mb-1"><code> ![{t('sandbox.alt_text')}](URL)</code> {t('sandbox.insert_image')}</p>
+        {renderCheetSheetElm(imageStr)}
+
+        <hr />
+        <a href="/Sandbox" className="btn btn-info btn-block" target="_blank">
+          <i className="icon-share-alt" /> {t('sandbox.open_sandbox')}
+        </a>
+      </div>
+    </div>
+  );
+
+};

+ 0 - 8
packages/app/src/components/PageEditor/Editor.module.scss

@@ -154,11 +154,3 @@
     }
     }
   }
   }
 }
 }
-
-.modal-gfm-cheatsheet :global {
-  .modal-body {
-    .hljs {
-      font-family: var(--font-family-monospace);
-    }
-  }
-}

+ 2 - 2
packages/app/src/components/PageEditor/Editor.tsx

@@ -18,7 +18,7 @@ import { useIsMobile } from '~/stores/ui';
 import { IEditorMethods } from '../../interfaces/editor-methods';
 import { IEditorMethods } from '../../interfaces/editor-methods';
 
 
 import AbstractEditor from './AbstractEditor';
 import AbstractEditor from './AbstractEditor';
-import Cheatsheet from './Cheatsheet';
+import { Cheatsheet } from './Cheatsheet';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import pasteHelper from './PasteHelper';
 import pasteHelper from './PasteHelper';
 import TextAreaEditor from './TextAreaEditor';
 import TextAreaEditor from './TextAreaEditor';
@@ -246,7 +246,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     };
     };
 
 
     return (
     return (
-      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className={`${styles['modal-gfm-cheatsheet']}`} >
+      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className={`modal-gfm-cheatsheet ${styles['modal-gfm-cheatsheet']}`} >
         <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
         <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
           <i className="icon-fw icon-question" />Markdown help
           <i className="icon-fw icon-question" />Markdown help
         </ModalHeader>
         </ModalHeader>

+ 3 - 2
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic';
 import { Collapse, Button } from 'reactstrap';
 import { Collapse, Button } from 'reactstrap';
 
 
 
 
+import { SavePageControlsProps } from '~/components/SavePageControls';
 import { useIsSlackConfigured } from '~/stores/context';
 import { useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
@@ -12,7 +13,7 @@ import {
 } from '~/stores/ui';
 } from '~/stores/ui';
 
 
 
 
-const SavePageControls = dynamic(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
+const SavePageControls = dynamic<SavePageControlsProps>(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
 const SlackLogo = dynamic(() => import('~/components/SlackLogo').then(mod => mod.SlackLogo), { ssr: false });
 const SlackLogo = dynamic(() => import('~/components/SlackLogo').then(mod => mod.SlackLogo), { ssr: false });
 const SlackNotification = dynamic(() => import('~/components/SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
 const SlackNotification = dynamic(() => import('~/components/SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
 const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
 const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
@@ -129,7 +130,7 @@ const EditorNavbarBottom = (): JSX.Element => {
               )}
               )}
             </div>
             </div>
           ))}
           ))}
-          <SavePageControls />
+          <SavePageControls slackChannels={slackChannelsStr} />
           { isCollapsedOptionsSelectorEnabled && renderExpandButton() }
           { isCollapsedOptionsSelectorEnabled && renderExpandButton() }
         </form>
         </form>
       </div>
       </div>

+ 38 - 41
packages/app/src/components/PageEditorByHackmd.tsx

@@ -1,5 +1,5 @@
 import React, {
 import React, {
-  useCallback, useRef, useState, useEffect,
+  useCallback, useRef, useState, useEffect, useMemo,
 } from 'react';
 } from 'react';
 
 
 import EventEmitter from 'events';
 import EventEmitter from 'events';
@@ -19,13 +19,13 @@ import {
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+  useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 } from '~/stores/editor';
 import {
 import {
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
 } from '~/stores/hackmd';
 } from '~/stores/hackmd';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
-import { useRemoteRevisionId, useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
+import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import {
 import {
   EditorMode,
   EditorMode,
   useEditorMode, useSelectedGrant,
   useEditorMode, useSelectedGrant,
@@ -56,12 +56,11 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
-  const { data: grant } = useSelectedGrant();
+  const { data: grantData } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
   const { data: hackmdUri } = useHackmdUri();
   const saveOrUpdate = useSaveOrUpdate();
   const saveOrUpdate = useSaveOrUpdate();
 
 
@@ -71,8 +70,6 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const revision = pageData?.revision;
   const revision = pageData?.revision;
 
 
-  const slackChannels = slackChannelsData?.toString();
-
   const [isInitialized, setIsInitialized] = useState(false);
   const [isInitialized, setIsInitialized] = useState(false);
   const [isInitializing, setIsInitializing] = useState(false);
   const [isInitializing, setIsInitializing] = useState(false);
   // for error
   // for error
@@ -88,33 +85,38 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: isHackmdDraftUpdatingInRealtime, mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
   const { data: isHackmdDraftUpdatingInRealtime, mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
   const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
   const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
 
 
-  const updateStateAfterSave = useUpdateStateAfterSave();
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
 
 
   const hackmdEditorRef = useRef<HackEditorRef>(null);
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
 
+  const optionsToSave = useMemo((): OptionsToSave | undefined => {
+    if (grantData == null) {
+      return;
+    }
+    const optionsToSave = {
+      isSlackEnabled: isSlackEnabled ?? false,
+      slackChannels: '', // set in save method by opts in SavePageControlls.tsx
+      grant: grantData.grant,
+      pageTags: pageTags ?? [],
+      grantUserGroupId: grantData.grantedGroup?.id,
+      grantUserGroupName: grantData.grantedGroup?.name,
+    };
+    return optionsToSave;
+  }, [grantData, isSlackEnabled, pageTags]);
+
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
     if (editorMode !== EditorMode.HackMD) { return }
     if (editorMode !== EditorMode.HackMD) { return }
 
 
     try {
     try {
-      if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null
-          || revision == null || hackmdEditorRef.current == null || revisionIdHackmdSynced == null) {
+      if (currentPathname == null || revision == null || hackmdEditorRef.current == null || revisionIdHackmdSynced == null || optionsToSave == null) {
         throw new Error('Some materials to save are invalid');
         throw new Error('Some materials to save are invalid');
       }
       }
 
 
-      const optionsToSave: OptionsToSave = {
-        isSlackEnabled,
-        slackChannels,
-        grant: grant.grant,
-        grantUserGroupId: grant.grantedGroup?.id,
-        grantUserGroupName: grant.grantedGroup?.name,
-        pageTags: pageTags ?? [],
-        isSyncRevisionToHackmd: true,
-        ...opts,
-      };
+      const options = Object.assign(optionsToSave, opts, { isSyncRevisionToHackmd: true });
 
 
       const markdown = await hackmdEditorRef.current.getValue();
       const markdown = await hackmdEditorRef.current.getValue();
 
 
-      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, optionsToSave);
+      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, options);
       await mutatePageData();
       await mutatePageData();
       await mutateTagsInfo();
       await mutateTagsInfo();
 
 
@@ -125,7 +127,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
         await router.push(`/${page._id}`);
         await router.push(`/${page._id}`);
       }
       }
       else {
       else {
-        updateStateAfterSave(page._id);
+        updateStateAfterSave?.();
       }
       }
       setIsInitialized(false);
       setIsInitialized(false);
       mutateEditorMode(EditorMode.View);
       mutateEditorMode(EditorMode.View);
@@ -135,7 +137,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       toastError(error.message);
       toastError(error.message);
     }
     }
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, revisionIdHackmdSynced, pageTags, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, useUpdateStateAfterSave]);
+  }, [editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
 
 
   // set handler to save and reload Page
   // set handler to save and reload Page
   useEffect(() => {
   useEffect(() => {
@@ -160,6 +162,11 @@ export const PageEditorByHackmd = (): JSX.Element => {
     };
     };
   }, [resetInitializedStatusHandler]);
   }, [resetInitializedStatusHandler]);
 
 
+  useEffect(() => {
+    // for page translation: https://github.com/weseek/growi/pull/7100
+    setIsInitialized(false);
+  }, [pageId]);
+
 
 
   const isResume = useCallback(() => {
   const isResume = useCallback(() => {
     const isPageExistsOnHackmd = (pageIdOnHackmd != null);
     const isPageExistsOnHackmd = (pageIdOnHackmd != null);
@@ -238,25 +245,18 @@ export const PageEditorByHackmd = (): JSX.Element => {
     try {
     try {
       const currentPagePathOrPathname = currentPagePath || currentPathname;
       const currentPagePathOrPathname = currentPagePath || currentPathname;
       if (
       if (
-        isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
-        || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
+        pageId == null || revisionIdHackmdSynced == null || currentPagePathOrPathname == null || optionsToSave == null
       ) { throw new Error('Some materials to save are invalid') }
       ) { throw new Error('Some materials to save are invalid') }
-      const optionsToSave = {
-        isSlackEnabled,
-        slackChannels,
-        grant: grant.grant,
-        grantUserGroupId: grant.grantedGroup?.id,
-        grantUserGroupName: grant.grantedGroup?.name,
-        pageTags: pageTags ?? [],
-        isSyncRevisionToHackmd: true,
-      };
-      const res = await saveOrUpdate(markdown, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, optionsToSave);
+
+      const options = Object.assign(optionsToSave, { isSyncRevisionToHackmd: true });
+
+      const res = await saveOrUpdate(markdown, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, options);
 
 
       // update pageData
       // update pageData
       mutatePageData(res);
       mutatePageData(res);
 
 
       // set updated data
       // set updated data
-      updateStateAfterSave(res._id);
+      updateStateAfterSave?.();
       mutateTagsInfo();
       mutateTagsInfo();
 
 
       logger.debug('success to save');
       logger.debug('success to save');
@@ -267,10 +267,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       toastError(error.message);
       toastError(error.message);
     }
     }
-  }, [
-    currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced,
-    pageTags, saveOrUpdate, mutatePageData, useUpdateStateAfterSave, mutateTagsInfo, t,
-  ]);
+  }, [currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, t]);
 
 
   /**
   /**
    * onChange event of HackmdEditor handler
    * onChange event of HackmdEditor handler

+ 5 - 3
packages/app/src/components/PageStatusAlert.tsx

@@ -10,6 +10,7 @@ import {
 import { useConflictDiffModal } from '~/stores/modal';
 import { useConflictDiffModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useRemoteRevisionId, useRemoteRevisionLastUpdateUser } from '~/stores/remote-latest-page';
 import { useRemoteRevisionId, useRemoteRevisionLastUpdateUser } from '~/stores/remote-latest-page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 import { Username } from './User/Username';
 import { Username } from './User/Username';
 
 
@@ -29,6 +30,7 @@ export const PageStatusAlert = (): JSX.Element => {
   const { data: isConflict } = useIsConflict();
   const { data: isConflict } = useIsConflict();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { open: openConflictDiffModal } = useConflictDiffModal();
   const { open: openConflictDiffModal } = useConflictDiffModal();
+  const { mutate: mutateEditorMode } = useEditorMode();
 
 
   // store remote latest page data
   // store remote latest page data
   const { data: revisionIdHackmdSynced } = useRevisionIdHackmdSynced();
   const { data: revisionIdHackmdSynced } = useRevisionIdHackmdSynced();
@@ -72,12 +74,12 @@ export const PageStatusAlert = (): JSX.Element => {
           {t('hackmd.this_page_has_draft')}
           {t('hackmd.this_page_has_draft')}
         </>,
         </>,
       btn:
       btn:
-        <a href="#hackmd" key="btnOpenHackmdPageHasDraft" className="btn btn-outline-white">
+        <button onClick={() => mutateEditorMode(EditorMode.HackMD)} className="btn btn-outline-white">
           <i className="fa fa-fw fa-file-text-o mr-1"></i>
           <i className="fa fa-fw fa-file-text-o mr-1"></i>
           Open HackMD Editor
           Open HackMD Editor
-        </a>,
+        </button>,
     };
     };
-  }, [t]);
+  }, [mutateEditorMode, t]);
 
 
   const getContentsForUpdatedAlert = useCallback((): AlertComponentContents => {
   const getContentsForUpdatedAlert = useCallback((): AlertComponentContents => {
 
 

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

@@ -30,7 +30,12 @@ const logger = loggerFactory('growi:SavePageControls');
 
 
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 
 
-export const SavePageControls = (): JSX.Element | null => {
+export type SavePageControlsProps = {
+  slackChannels: string
+}
+
+export const SavePageControls = (props: SavePageControlsProps): JSX.Element | null => {
+  const { slackChannels } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
@@ -45,13 +50,13 @@ export const SavePageControls = (): JSX.Element | null => {
 
 
   const save = useCallback(async(): Promise<void> => {
   const save = useCallback(async(): Promise<void> => {
     // save
     // save
-    globalEmitter.emit('saveAndReturnToView');
-  }, []);
+    globalEmitter.emit('saveAndReturnToView', { slackChannels });
+  }, [slackChannels]);
 
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // save
     // save
-    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
-  }, []);
+    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true, slackChannels });
+  }, [slackChannels]);
 
 
 
 
   if (isEditable == null || isAclEnabled == null || grantData == null) {
   if (isEditable == null || isAclEnabled == null || grantData == null) {

+ 4 - 2
packages/app/src/components/Sidebar.tsx

@@ -17,6 +17,7 @@ import {
 import DrawerToggler from './Navbar/DrawerToggler';
 import DrawerToggler from './Navbar/DrawerToggler';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
 import { SidebarNav } from './Sidebar/SidebarNav';
 import { SidebarNav } from './Sidebar/SidebarNav';
+import { SidebarSkeleton } from './Sidebar/Skeleton/SidebarSkeleton';
 import { StickyStretchableScrollerProps } from './StickyStretchableScroller';
 import { StickyStretchableScrollerProps } from './StickyStretchableScroller';
 
 
 import styles from './Sidebar.module.scss';
 import styles from './Sidebar.module.scss';
@@ -57,8 +58,9 @@ const GlobalNavigation = () => {
 
 
 const SidebarContentsWrapper = () => {
 const SidebarContentsWrapper = () => {
   const StickyStretchableScroller = dynamic<StickyStretchableScrollerProps>(() => import('./StickyStretchableScroller')
   const StickyStretchableScroller = dynamic<StickyStretchableScrollerProps>(() => import('./StickyStretchableScroller')
-    .then(mod => mod.StickyStretchableScroller), { ssr: false });
-  const SidebarContents = dynamic(() => import('./Sidebar/SidebarContents').then(mod => mod.SidebarContents), { ssr: false });
+    .then(mod => mod.StickyStretchableScroller), { ssr: false, loading: () => <SidebarSkeleton /> });
+  const SidebarContents = dynamic(() => import('./Sidebar/SidebarContents')
+    .then(mod => mod.SidebarContents), { ssr: false, loading: () => <SidebarSkeleton /> });
   const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
   const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
 
 
   const calcViewHeight = useCallback(() => {
   const calcViewHeight = useCallback(() => {

+ 12 - 0
packages/app/src/components/Sidebar/CustomSidebar.module.scss

@@ -1,7 +1,19 @@
 @use '~/styles/organisms/wiki-custom-sidebar.scss';
 @use '~/styles/organisms/wiki-custom-sidebar.scss';
+@use '~/styles/mixins' as *;
 
 
 .grw-custom-sidebar-content :global {
 .grw-custom-sidebar-content :global {
   .wiki {
   .wiki {
     @extend %grw-custom-sidebar-content;
     @extend %grw-custom-sidebar-content;
   }
   }
+
+  .grw-custom-sidebar-skeleton-text {
+    @include grw-skeleton-text($font-size:15px, $line-height:21.42px);
+    max-width: 160px;
+    margin: 15px 0;
+  }
+
+  .grw-custom-sidebar-skeleton-text-full {
+    @extend .grw-custom-sidebar-skeleton-text;
+    max-width: 100%;
+  }
 }
 }

+ 11 - 15
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -10,6 +10,8 @@ import loggerFactory from '~/utils/logger';
 
 
 import RevisionRenderer from '../Page/RevisionRenderer';
 import RevisionRenderer from '../Page/RevisionRenderer';
 
 
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import CustomSidebarContentSkeleton from './Skeleton/CustomSidebarContentSkeleton';
 
 
 import styles from './CustomSidebar.module.scss';
 import styles from './CustomSidebar.module.scss';
 
 
@@ -19,11 +21,9 @@ const logger = loggerFactory('growi:cli:CustomSidebar');
 
 
 const SidebarNotFound = () => {
 const SidebarNotFound = () => {
   return (
   return (
-    <div className="grw-sidebar-content-header h5 text-center p-3">
+    <div className="grw-sidebar-content-header h5 text-center py-3">
       <Link href="/Sidebar#edit">
       <Link href="/Sidebar#edit">
-        <a href="/Sidebar#edit">
-          <i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page
-        </a>
+        <a><i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page</a>
       </Link>
       </Link>
     </div>
     </div>
   );
   );
@@ -43,28 +43,24 @@ const CustomSidebar: FC = () => {
   const markdown = (page?.revision as IRevision | undefined)?.body;
   const markdown = (page?.revision as IRevision | undefined)?.body;
 
 
   return (
   return (
-    <>
-      <div className="grw-sidebar-content-header p-3 d-flex">
+    <div className="px-3">
+      <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">
         <h3 className="mb-0">
           {t('CustomSidebar')}
           {t('CustomSidebar')}
-          <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
+          <Link href="/Sidebar"><a className="h6 ml-2"><i className="icon-pencil"></i></a></Link>
         </h3>
         </h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => mutate()}>
-          <i className="icon icon-reload"></i>
-        </button>
+        <SidebarHeaderReloadButton onClick={() => mutate()} />
       </div>
       </div>
 
 
       {
       {
         isLoading && (
         isLoading && (
-          <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
-          </div>
+          <CustomSidebarContentSkeleton />
         )
         )
       }
       }
 
 
       {
       {
         (!isLoading && markdown != null) && (
         (!isLoading && markdown != null) && (
-          <div className={`p-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
+          <div className={`py-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
             <RevisionRenderer
             <RevisionRenderer
               rendererOptions={rendererOptions}
               rendererOptions={rendererOptions}
               markdown={markdown}
               markdown={markdown}
@@ -78,7 +74,7 @@ const CustomSidebar: FC = () => {
           <SidebarNotFound />
           <SidebarNotFound />
         )
         )
       }
       }
-    </>
+    </div>
   );
   );
 };
 };
 
 

+ 22 - 20
packages/app/src/components/Sidebar/PageTree.tsx

@@ -10,6 +10,17 @@ import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 
 
 import ItemsTree from './PageTree/ItemsTree';
 import ItemsTree from './PageTree/ItemsTree';
 import { PrivateLegacyPagesLink } from './PageTree/PrivateLegacyPagesLink';
 import { PrivateLegacyPagesLink } from './PageTree/PrivateLegacyPagesLink';
+import PageTreeContentSkeleton from './Skeleton/PageTreeContentSkeleton';
+
+const PageTreeHeader = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="grw-sidebar-content-header py-3 d-flex">
+      <h3 className="mb-0">{t('Page Tree')}</h3>
+    </div>
+  );
+};
 
 
 const PageTree: FC = memo(() => {
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -24,14 +35,10 @@ const PageTree: FC = memo(() => {
 
 
   if (migrationStatus == null) {
   if (migrationStatus == null) {
     return (
     return (
-      <>
-        <div className="grw-sidebar-content-header p-3">
-          <h3 className="mb-0">{t('Page Tree')}</h3>
-        </div>
-        <div className="text-muted text-center mt-3">
-          <i className="fa fa-lg fa-spinner fa-pulse mr-1"></i>
-        </div>
-      </>
+      <div className="px-3">
+        <PageTreeHeader />
+        <PageTreeContentSkeleton />
+      </div>
     );
     );
   }
   }
 
 
@@ -39,15 +46,13 @@ const PageTree: FC = memo(() => {
     // TODO : improve design
     // TODO : improve design
     // Story : https://redmine.weseek.co.jp/issues/83755
     // Story : https://redmine.weseek.co.jp/issues/83755
     return (
     return (
-      <>
-        <div className="grw-sidebar-content-header p-3">
-          <h3 className="mb-0">{t('Page Tree')}</h3>
-        </div>
+      <div className="px-3">
+        <PageTreeHeader />
         <div className="mt-5 mx-2 text-center">
         <div className="mt-5 mx-2 text-center">
           <h3 className="text-gray">{t('v5_page_migration.page_tree_not_avaliable')}</h3>
           <h3 className="text-gray">{t('v5_page_migration.page_tree_not_avaliable')}</h3>
           <a href="/admin">{t('v5_page_migration.go_to_settings')}</a>
           <a href="/admin">{t('v5_page_migration.go_to_settings')}</a>
         </div>
         </div>
-      </>
+      </div>
     );
     );
   }
   }
 
 
@@ -61,11 +66,8 @@ const PageTree: FC = memo(() => {
   const path = currentPath || '/';
   const path = currentPath || '/';
 
 
   return (
   return (
-    <>
-      <div className="grw-sidebar-content-header p-3">
-        <h3 className="mb-0">{t('Page Tree')}</h3>
-      </div>
-
+    <div className="px-3">
+      <PageTreeHeader />
       <ItemsTree
       <ItemsTree
         isEnableActions={!isGuestUser}
         isEnableActions={!isGuestUser}
         targetPath={path}
         targetPath={path}
@@ -74,13 +76,13 @@ const PageTree: FC = memo(() => {
       />
       />
 
 
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
-        <div className="grw-pagetree-footer border-top p-3 w-100">
+        <div className="grw-pagetree-footer border-top py-3 w-100">
           <div className="private-legacy-pages-link px-3 py-2">
           <div className="private-legacy-pages-link px-3 py-2">
             <PrivateLegacyPagesLink />
             <PrivateLegacyPagesLink />
           </div>
           </div>
         </div>
         </div>
       )}
       )}
-    </>
+    </div>
   );
   );
 });
 });
 
 

+ 14 - 1
packages/app/src/components/Sidebar/PageTree/ItemsTree.module.scss

@@ -1,9 +1,22 @@
 @use '~/styles/variables' as var;
 @use '~/styles/variables' as var;
+@use '~/styles/mixins' as *;
 $grw-sidebar-content-header-height: 58px;
 $grw-sidebar-content-header-height: 58px;
 $grw-sidebar-content-footer-height: 50px;
 $grw-sidebar-content-footer-height: 50px;
 $grw-pagetree-item-padding-left: 10px;
 $grw-pagetree-item-padding-left: 10px;
+$grw-pagetree-item-container-height: 40px;
 
 
 .grw-pagetree {
 .grw-pagetree {
+
+  .grw-pagetree-item-skeleton-text {
+    @include grw-skeleton-text($font-size:16px, $line-height:$grw-pagetree-item-container-height);
+    padding-left: 12px;
+  }
+
+  .grw-pagetree-item-skeleton-text-child {
+    @extend .grw-pagetree-item-skeleton-text;
+    padding-left: 12px + $grw-pagetree-item-padding-left;
+  }
+
   :global {
   :global {
     min-height: calc(100vh - (var.$grw-navbar-height + var.$grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
     min-height: calc(100vh - (var.$grw-navbar-height + var.$grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
 
 
@@ -56,7 +69,7 @@ $grw-pagetree-item-padding-left: 10px;
     .grw-pagetree-item-container {
     .grw-pagetree-item-container {
       .grw-triangle-container {
       .grw-triangle-container {
         min-width: 35px;
         min-width: 35px;
-        height: 40px;
+        height: $grw-pagetree-item-container-height;
       }
       }
     }
     }
   }
   }

+ 4 - 2
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -24,6 +24,8 @@ import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 import { useGlobalSocket } from '~/stores/websocket';
 import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import PageTreeContentSkeleton from '../Skeleton/PageTreeContentSkeleton';
+
 import Item from './Item';
 import Item from './Item';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
 
 
@@ -272,7 +274,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
 
   if (initialItemNode != null) {
   if (initialItemNode != null) {
     return (
     return (
-      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group p-3`} ref={rootElemRef}>
+      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} ref={rootElemRef}>
         <Item
         <Item
           key={initialItemNode.page.path}
           key={initialItemNode.page.path}
           targetPathOrId={targetPathOrId}
           targetPathOrId={targetPathOrId}
@@ -288,7 +290,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
     );
     );
   }
   }
 
 
-  return <></>;
+  return <PageTreeContentSkeleton />;
 };
 };
 
 
 export default ItemsTree;
 export default ItemsTree;

+ 17 - 0
packages/app/src/components/Sidebar/RecentChanges.module.scss

@@ -1,3 +1,5 @@
+@use '~/styles/mixins' as *;
+
 .grw-recent-changes-resize-button :global {
 .grw-recent-changes-resize-button :global {
   font-size: 12px;
   font-size: 12px;
   line-height: normal;
   line-height: normal;
@@ -15,6 +17,21 @@
 }
 }
 
 
 .list-group-item :global {
 .list-group-item :global {
+  .grw-recent-changes-skeleton-small {
+    @include grw-skeleton-text($font-size:14px, $line-height:16px);
+    max-width: 120px;
+  }
+
+  .grw-recent-changes-skeleton-h5 {
+    @include grw-skeleton-h5;
+    max-width: 120px;
+  }
+
+  .grw-recent-changes-skeleton-date {
+    @include grw-skeleton-text($font-size:10px, $line-height:12px);
+    width: 90px;
+  }
+
   .grw-recent-changes-item-lower {
   .grw-recent-changes-item-lower {
     height: 17.5px;
     height: 17.5px;
   }
   }

+ 43 - 67
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -16,6 +16,8 @@ import loggerFactory from '~/utils/logger';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 
 
 import InfiniteScroll from './InfiniteScroll';
 import InfiniteScroll from './InfiniteScroll';
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import RecentChangesContentSkeleton from './Skeleton/RecentChangesContentSkeleton';
 
 
 import TagLabelsStyles from '../Page/TagLabels.module.scss';
 import TagLabelsStyles from '../Page/TagLabels.module.scss';
 import styles from './RecentChanges.module.scss';
 import styles from './RecentChanges.module.scss';
@@ -23,11 +25,15 @@ import styles from './RecentChanges.module.scss';
 
 
 const logger = loggerFactory('growi:History');
 const logger = loggerFactory('growi:History');
 
 
-type PageItemProps = {
+type PageItemLowerProps = {
   page: IPageHasId,
   page: IPageHasId,
 }
 }
 
 
-const PageItemLower = memo(({ page }: PageItemProps): JSX.Element => {
+type PageItemProps = PageItemLowerProps & {
+  isSmall: boolean
+}
+
+const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
   return (
   return (
     <div className="d-flex justify-content-between grw-recent-changes-item-lower pt-1">
     <div className="d-flex justify-content-between grw-recent-changes-item-lower pt-1">
       <div className="d-flex">
       <div className="d-flex">
@@ -44,8 +50,7 @@ const PageItemLower = memo(({ page }: PageItemProps): JSX.Element => {
 });
 });
 PageItemLower.displayName = 'PageItemLower';
 PageItemLower.displayName = 'PageItemLower';
 
 
-
-const LargePageItem = memo(({ page }: PageItemProps): JSX.Element => {
+const PageItem = memo(({ page, isSmall }: PageItemProps): JSX.Element => {
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
@@ -75,67 +80,38 @@ const LargePageItem = memo(({ page }: PageItemProps): JSX.Element => {
   });
   });
 
 
   return (
   return (
-    <li className={`list-group-item ${styles['list-group-item']} py-3 px-0`}>
+    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
       <div className="d-flex w-100">
       <div className="d-flex w-100">
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
         <div className="flex-grow-1 ml-2">
         <div className="flex-grow-1 ml-2">
           { !dPagePath.isRoot && <FormerLink /> }
           { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className="my-2">
+          <h5 className={isSmall ? 'my-0 text-truncate' : 'my-2'}>
             <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
             <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
             {locked}
             {locked}
           </h5>
           </h5>
-          <div className="grw-tag-labels mt-1 mb-2">
+          {!isSmall && <div className="grw-tag-labels mt-1 mb-2">
             { tagElements }
             { tagElements }
-          </div>
+          </div>}
           <PageItemLower page={page} />
           <PageItemLower page={page} />
         </div>
         </div>
       </div>
       </div>
     </li>
     </li>
   );
   );
 });
 });
-LargePageItem.displayName = 'LargePageItem';
-
-
-const SmallPageItem = memo(({ page }: PageItemProps): JSX.Element => {
-  const dPagePath = new DevidedPagePath(page.path, false, true);
-  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-  const FormerLink = () => (
-    <div className="grw-page-path-text-muted-container small">
-      <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
-    </div>
-  );
-
-  let locked;
-  if (page.grant !== 1) {
-    locked = <span><i className="icon-lock ml-2" /></span>;
-  }
-
-  return (
-    <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
-      <div className="d-flex w-100">
-        <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
-        <div className="flex-grow-1 ml-2">
-          { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className="my-0 text-truncate">
-            <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
-            {locked}
-          </h5>
-          <PageItemLower page={page} />
-        </div>
-      </div>
-    </li>
-  );
-});
-SmallPageItem.displayName = 'SmallPageItem';
+PageItem.displayName = 'PageItem';
 
 
 const RecentChanges = (): JSX.Element => {
 const RecentChanges = (): JSX.Element => {
+
   const PER_PAGE = 20;
   const PER_PAGE = 20;
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const swr = useSWRInifinitexRecentlyUpdated();
+  const swrInifinitexRecentlyUpdated = useSWRInifinitexRecentlyUpdated();
+  const { data: dataRecentlyUpdated, error, mutate: mutateRecentlyUpdated } = swrInifinitexRecentlyUpdated;
+
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
-  const isEmpty = swr.data?.[0].length === 0;
-  const isReachingEnd = isEmpty || (swr.data && swr.data[swr.data.length - 1]?.length < PER_PAGE);
+  const isEmpty = dataRecentlyUpdated?.[0].length === 0;
+  const isLoading = error == null && dataRecentlyUpdated === undefined;
+  const isReachingEnd = isEmpty || (dataRecentlyUpdated && dataRecentlyUpdated[dataRecentlyUpdated.length - 1]?.length < PER_PAGE);
+
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
       setIsRecentChangesSidebarSmall(true);
@@ -153,12 +129,10 @@ const RecentChanges = (): JSX.Element => {
   }, [retrieveSizePreferenceFromLocalStorage]);
   }, [retrieveSizePreferenceFromLocalStorage]);
 
 
   return (
   return (
-    <div data-testid="grw-recent-changes">
-      <div className="grw-sidebar-content-header p-3 d-flex">
-        <h3 className="mb-0  text-nowrap">{t('Recent Changes')}</h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => swr.mutate()}>
-          <i className="icon icon-reload"></i>
-        </button>
+    <div className="px-3" data-testid="grw-recent-changes">
+      <div className="grw-sidebar-content-header py-3 d-flex">
+        <h3 className="mb-0 text-nowrap">{t('Recent Changes')}</h3>
+        <SidebarHeaderReloadButton onClick={() => mutateRecentlyUpdated()}/>
         <div className="d-flex align-items-center">
         <div className="d-flex align-items-center">
           <div className={`grw-recent-changes-resize-button ${styles['grw-recent-changes-resize-button']} custom-control custom-switch ml-1`}>
           <div className={`grw-recent-changes-resize-button ${styles['grw-recent-changes-resize-button']} custom-control custom-switch ml-1`}>
             <input
             <input
@@ -173,21 +147,23 @@ const RecentChanges = (): JSX.Element => {
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
-      <div className="grw-recent-changes p-3">
-        <ul className="list-group list-group-flush">
-          <InfiniteScroll
-            swrInifiniteResponse={swr}
-            isReachingEnd={isReachingEnd}
-          >
-            {pages => pages.map(page => (
-              isRecentChangesSidebarSmall
-                ? <SmallPageItem key={page._id} page={page} />
-                : <LargePageItem key={page._id} page={page} />
-            ))
-            }
-          </InfiniteScroll>
-        </ul>
-      </div>
+      {
+        isLoading ? <RecentChangesContentSkeleton /> : (
+          <div className="grw-recent-changes py-3">
+            <ul className="list-group list-group-flush">
+              <InfiniteScroll
+                swrInifiniteResponse={swrInifinitexRecentlyUpdated}
+                isReachingEnd={isReachingEnd}
+              >
+                {pages => pages.map(
+                  page => <PageItem key={page._id} page={page} isSmall={isRecentChangesSidebarSmall} />,
+                )
+                }
+              </InfiniteScroll>
+            </ul>
+          </div>
+        )
+      }
     </div>
     </div>
   );
   );
 
 

+ 14 - 0
packages/app/src/components/Sidebar/SidebarHeaderReloadButton.tsx

@@ -0,0 +1,14 @@
+import React from 'react';
+
+type Props = {
+  onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
+};
+
+export const SidebarHeaderReloadButton = ({ onClick }: Props) => {
+
+  return (
+    <button type="button" className="btn btn-sm ml-auto py-0 grw-btn-reload" onClick={onClick}>
+      <i className="icon icon-reload"></i>
+    </button>
+  );
+};

+ 18 - 0
packages/app/src/components/Sidebar/Skeleton/CustomSidebarContentSkeleton.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../CustomSidebar.module.scss';
+
+const CustomSidebarContentSkeleton = (): JSX.Element => {
+
+  return (
+    <div className={`py-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text-full ${styles['grw-custom-sidebar-skeleton-text-full']}`} />
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text-full ${styles['grw-custom-sidebar-skeleton-text-full']}`} />
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text ${styles['grw-custom-sidebar-skeleton-text']}`} />
+    </div>
+  );
+};
+
+export default CustomSidebarContentSkeleton;

+ 18 - 0
packages/app/src/components/Sidebar/Skeleton/PageTreeContentSkeleton.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../PageTree/ItemsTree.module.scss';
+
+const PageTreeContentSkeleton = (): JSX.Element => {
+
+  return (
+    <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} >
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text']} pr-3`} />
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text-child']} pr-3`} />
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text-child']} pr-3`} />
+    </ul>
+  );
+};
+
+export default PageTreeContentSkeleton;

+ 40 - 0
packages/app/src/components/Sidebar/Skeleton/RecentChangesContentSkeleton.tsx

@@ -0,0 +1,40 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../RecentChanges.module.scss';
+
+const SkeletonItem = () => {
+
+  const isSmall = window.localStorage.isRecentChangesSidebarSmall === 'true';
+
+  return (
+    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
+      <div className="d-flex w-100">
+        <Skeleton additionalClass='rounded-circle picture' roundedPill />
+        <div className="flex-grow-1 ml-2">
+          <Skeleton additionalClass={`grw-recent-changes-skeleton-small ${styles['grw-recent-changes-skeleton-small']}`} />
+          <Skeleton additionalClass={`grw-recent-changes-skeleton-h5 ${styles['grw-recent-changes-skeleton-h5']} ${isSmall ? 'my-0' : 'my-2'}`} />
+          <div className="d-flex justify-content-end grw-recent-changes-item-lower pt-1">
+            <Skeleton additionalClass={`grw-recent-changes-skeleton-date ${styles['grw-recent-changes-skeleton-date']}`} />
+          </div>
+        </div>
+      </div>
+    </li>
+  );
+};
+
+const RecentChangesContentSkeleton = (): JSX.Element => {
+
+  return (
+    <div className="grw-recent-changes py-3">
+      <ul className="list-group list-group-flush">
+        <SkeletonItem />
+        <SkeletonItem />
+        <SkeletonItem />
+        <li className={'list-group-item p-0'}></li>
+      </ul>
+    </div>);
+};
+
+export default RecentChangesContentSkeleton;

+ 6 - 0
packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.module.scss

@@ -0,0 +1,6 @@
+@use '~/styles/mixins' as *;
+
+.grw-sidebar-content-header-skeleton {
+  @include grw-skeleton-h3;
+  max-width: 100%;
+}

+ 50 - 0
packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.tsx

@@ -0,0 +1,50 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentSidebarContents } from '~/stores/ui';
+
+import CustomSidebarContentSkeleton from './CustomSidebarContentSkeleton';
+import PageTreeContentSkeleton from './PageTreeContentSkeleton';
+import RecentChangesContentSkeleton from './RecentChangesContentSkeleton';
+import TagContentSkeleton from './TagContentSkeleton';
+
+import styles from './SidebarSkeleton.module.scss';
+
+export const SidebarHeaderSkeleton = (): JSX.Element => {
+  return (
+    <div className="grw-sidebar-content-header py-3">
+      <Skeleton additionalClass={styles['grw-sidebar-content-header-skeleton']} />
+    </div>
+  );
+};
+
+export const SidebarSkeleton = (): JSX.Element => {
+
+  const { data: currentSidebarContents } = useCurrentSidebarContents();
+
+  let SidebarContentSkeleton: () => JSX.Element;
+  switch (currentSidebarContents) {
+
+    case SidebarContentsType.TAG:
+      SidebarContentSkeleton = TagContentSkeleton;
+      break;
+    case SidebarContentsType.RECENT:
+      SidebarContentSkeleton = RecentChangesContentSkeleton;
+      break;
+    case SidebarContentsType.CUSTOM:
+      SidebarContentSkeleton = CustomSidebarContentSkeleton;
+      break;
+    case SidebarContentsType.TREE:
+    default:
+      SidebarContentSkeleton = PageTreeContentSkeleton;
+      break;
+  }
+
+  return (
+    <div className={currentSidebarContents === SidebarContentsType.TAG ? 'px-4' : 'px-3'}>
+      <SidebarHeaderSkeleton />
+      <SidebarContentSkeleton />
+    </div>
+  );
+};

+ 23 - 0
packages/app/src/components/Sidebar/Skeleton/TagContentSkeleton.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../Tag.module.scss';
+
+export const TagListSkeleton = (): JSX.Element => {
+  return (
+    <Skeleton additionalClass={`${styles['grw-tag-list-skeleton']} w-100 rounded overflow-hidden`} />
+  );
+};
+
+const TagContentSkeleton = (): JSX.Element => {
+
+  return (
+    <>
+      <Skeleton additionalClass={`${styles['grw-tag-skeleton-h3']} my-3`} />
+      <TagListSkeleton />
+    </>
+  );
+};
+
+export default TagContentSkeleton;

+ 10 - 0
packages/app/src/components/Sidebar/Tag.module.scss

@@ -0,0 +1,10 @@
+@use '~/styles/mixins' as *;
+
+.grw-tag-skeleton-h3 {
+  @include grw-skeleton-h3;
+  max-width: 120px;
+}
+
+.grw-tag-list-skeleton {
+  height: 90px;
+}

+ 5 - 10
packages/app/src/components/Sidebar/Tag.tsx

@@ -9,6 +9,9 @@ import { useSWRxTagsList } from '~/stores/tag';
 import TagCloudBox from '../TagCloudBox';
 import TagCloudBox from '../TagCloudBox';
 import TagList from '../TagList';
 import TagList from '../TagList';
 
 
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import { TagListSkeleton } from './Skeleton/TagContentSkeleton';
+
 
 
 const PAGING_LIMIT = 10;
 const PAGING_LIMIT = 10;
 const TAG_CLOUD_LIMIT = 20;
 const TAG_CLOUD_LIMIT = 20;
@@ -44,22 +47,14 @@ const Tag: FC = () => {
     <div className="grw-container-convertible px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
     <div className="grw-container-convertible px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
       <div className="grw-sidebar-content-header py-3 d-flex">
       <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">{t('Tags')}</h3>
         <h3 className="mb-0">{t('Tags')}</h3>
-        <button
-          type="button"
-          className="btn btn-sm ml-auto grw-btn-reload"
-          onClick={onReload}
-        >
-          <i className="icon icon-reload"></i>
-        </button>
+        <SidebarHeaderReloadButton onClick={() => onReload()}/>
       </div>
       </div>
 
 
       <h3 className="my-3">{t('tag_list')}</h3>
       <h3 className="my-3">{t('tag_list')}</h3>
 
 
       { isLoading
       { isLoading
         ? (
         ? (
-          <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
-          </div>
+          <TagListSkeleton />
         )
         )
         : (
         : (
           <div data-testid="grw-tags-list">
           <div data-testid="grw-tags-list">

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

@@ -12,7 +12,7 @@ export const Skeleton = (props: SkeletonProps): JSX.Element => {
 
 
   return (
   return (
     <div className={`${additionalClass ?? ''}`}>
     <div className={`${additionalClass ?? ''}`}>
-      <div className={`grw-skeleton h-100 w-100 ${roundedPill ?? ''}`}></div>
+      <div className={`grw-skeleton h-100 w-100 ${roundedPill && 'rounded-pill'}`}></div>
     </div>
     </div>
   );
   );
 };
 };

+ 5 - 0
packages/app/src/interfaces/rehype.ts

@@ -4,3 +4,8 @@ export const RehypeSanitizeOption = {
 } as const;
 } as const;
 
 
 export type RehypeSanitizeOption = typeof RehypeSanitizeOption[keyof typeof RehypeSanitizeOption];
 export type RehypeSanitizeOption = typeof RehypeSanitizeOption[keyof typeof RehypeSanitizeOption];
+
+export type RehypeSanitizeOptionConfig = {
+  isEnabledXssPrevention: boolean,
+  // Todo add types for custom sanitize option at https://redmine.weseek.co.jp/issues/109763
+}

+ 3 - 1
packages/app/src/interfaces/services/renderer.ts

@@ -1,5 +1,7 @@
 import { XssOptionConfig } from '~/services/xss/xssOption';
 import { XssOptionConfig } from '~/services/xss/xssOption';
 
 
+import { RehypeSanitizeOptionConfig } from '../rehype';
+
 
 
 export type RendererConfig = {
 export type RendererConfig = {
   isEnabledLinebreaks: boolean,
   isEnabledLinebreaks: boolean,
@@ -10,4 +12,4 @@ export type RendererConfig = {
 
 
   plantumlUri: string | null,
   plantumlUri: string | null,
   blockdiagUri: string | null,
   blockdiagUri: string | null,
-} & XssOptionConfig;
+} & XssOptionConfig & RehypeSanitizeOptionConfig;

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

@@ -262,7 +262,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useRevisionIdHackmdSynced(pageWithMeta?.data.revisionHackmdSynced);
   useRevisionIdHackmdSynced(pageWithMeta?.data.revisionHackmdSynced);
   useRemoteRevisionId(pageWithMeta?.data.revision._id);
   useRemoteRevisionId(pageWithMeta?.data.revision._id);
   usePageIdOnHackmd(pageWithMeta?.data.pageIdOnHackmd);
   usePageIdOnHackmd(pageWithMeta?.data.pageIdOnHackmd);
-  useHasDraftOnHackmd(pageWithMeta?.data.hasDraftOnHackmd);
+  useHasDraftOnHackmd(pageWithMeta?.data.hasDraftOnHackmd ?? false);
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPathname(props.currentPathname);
   useCurrentPathname(props.currentPathname);
 
 
@@ -571,10 +571,12 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
     blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
 
 
     // XSS Options
     // XSS Options
-    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
     attrWhiteList: crowi.xssService.getAttrWhiteList(),
     attrWhiteList: crowi.xssService.getAttrWhiteList(),
     tagWhiteList: crowi.xssService.getTagWhiteList(),
     tagWhiteList: crowi.xssService.getTagWhiteList(),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+
+    // XSS: rehype-sanitize options
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
   };
   };
 
 
   props.sidebarConfig = {
   props.sidebarConfig = {

+ 0 - 2
packages/app/src/server/models/config.ts

@@ -150,8 +150,6 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
 };
 };
 
 
 export const defaultMarkdownConfigs: { [key: string]: any } = {
 export const defaultMarkdownConfigs: { [key: string]: any } = {
-  'markdown:xss:isEnabledPrevention': true,
-  'markdown:xss:option': 2,
   'markdown:xss:tagWhiteList': [],
   'markdown:xss:tagWhiteList': [],
   'markdown:xss:attrWhiteList': [],
   'markdown:xss:attrWhiteList': [],
   'markdown:rehypeSanitize:isEnabledPrevention': true,
   'markdown:rehypeSanitize:isEnabledPrevention': true,

+ 8 - 8
packages/app/src/server/routes/apiv3/markdown-setting.js

@@ -125,8 +125,8 @@ module.exports = (crowi) => {
       isIndentSizeForced: await crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
       isIndentSizeForced: await crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
       pageBreakSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
       pageBreakSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
       pageBreakCustomSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator'),
       pageBreakCustomSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator'),
-      isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
-      xssOption: await crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
+      isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
+      xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
       tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
       tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
       attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
       attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
     };
     };
@@ -293,17 +293,17 @@ module.exports = (crowi) => {
     }
     }
 
 
     const reqestXssParams = {
     const reqestXssParams = {
-      'markdown:xss:isEnabledPrevention': req.body.isEnabledXss,
-      'markdown:xss:option': req.body.xssOption,
-      'markdown:xss:tagWhiteList': req.body.tagWhiteList,
-      'markdown:xss:attrWhiteList': req.body.attrWhiteList,
+      'markdown:rehypeSanitize:isEnabledPrevention': req.body.isEnabledXss,
+      'markdown:rehypeSanitize:option': req.body.xssOption,
+      'markdown:xss:tagWhiteList': req.body.tagWhiteList, // Todo: need to be changed at https://redmine.weseek.co.jp/issues/109763
+      'markdown:xss:attrWhiteList': req.body.attrWhiteList, // Todo: need to be changed at https://redmine.weseek.co.jp/issues/109763
     };
     };
 
 
     try {
     try {
       await crowi.configManager.updateConfigsInTheSameNamespace('markdown', reqestXssParams);
       await crowi.configManager.updateConfigsInTheSameNamespace('markdown', reqestXssParams);
       const xssParams = {
       const xssParams = {
-        isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
-        xssOption: await crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
+        isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
+        xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
         tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
         tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
         attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
         attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
       };
       };

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

@@ -197,6 +197,7 @@ module.exports = function(crowi, app) {
     .get('/:token', applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.renderUserActivationPage(crowi))
     .get('/:token', applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.renderUserActivationPage(crowi))
     .use(userActivation.tokenErrorHandlerMiddeware(crowi)));
     .use(userActivation.tokenErrorHandlerMiddeware(crowi)));
 
 
+  app.get('/share$', (req, res) => res.redirect('/'));
   app.get('/share/:linkId', next.delegateToNext);
   app.get('/share/:linkId', next.delegateToNext);
 
 
   app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));
   app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));

+ 21 - 0
packages/app/src/styles/_mixins.scss

@@ -160,3 +160,24 @@
     content: $code;
     content: $code;
   }
   }
 }
 }
+
+@mixin grw-skeleton-text($font-size, $line-height) {
+  height: $line-height;
+  padding: (($line-height - $font-size)  / 2) 0;
+}
+/*
+.example {
+  @include grw-skeleton-text($font-size:$size, $line-height:$height);
+  max-width: 100%;
+}
+*/
+
+// values from './bootstrap/override'
+
+@mixin grw-skeleton-h3 {
+  @include grw-skeleton-text(21px, 30px);
+}
+
+@mixin grw-skeleton-h5 {
+  @include grw-skeleton-text(16px, 18px);
+}