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

Merge branch 'dev/7.0.x' into feat/135182-use-drawio-modal-in-editor

soumaeda 2 лет назад
Родитель
Сommit
5eefda89fc
73 измененных файлов с 896 добавлено и 587 удалено
  1. 0 5
      apps/app/_obsolete/src/styles/_override.scss
  2. 1 1
      apps/app/public/static/locales/en_US/translation.json
  3. 1 1
      apps/app/public/static/locales/ja_JP/commons.json
  4. 1 1
      apps/app/public/static/locales/ja_JP/translation.json
  5. 1 1
      apps/app/public/static/locales/zh_CN/translation.json
  6. 20 11
      apps/app/src/client/services/layout.ts
  7. 49 0
      apps/app/src/client/services/use-on-template-button-clicked.ts
  8. 18 21
      apps/app/src/components/Comments.tsx
  9. 32 10
      apps/app/src/components/Common/PageViewLayout.module.scss
  10. 13 5
      apps/app/src/components/Common/PageViewLayout.tsx
  11. 0 76
      apps/app/src/components/CreateTemplateModal.jsx
  12. 101 0
      apps/app/src/components/CreateTemplateModal.tsx
  13. 15 34
      apps/app/src/components/InAppNotification/InAppNotificationElm.tsx
  14. 2 11
      apps/app/src/components/InAppNotification/PageNotification/ModelNotification.tsx
  15. 34 25
      apps/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  16. 30 26
      apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx
  17. 19 0
      apps/app/src/components/InAppNotification/PageNotification/index.tsx
  18. 2 19
      apps/app/src/components/InAppNotification/PageNotification/useActionAndMsg.ts
  19. 7 7
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  20. 2 2
      apps/app/src/components/Navbar/PageEditorModeManager.module.scss
  21. 1 1
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  22. 22 10
      apps/app/src/components/Page/PageView.tsx
  23. 49 55
      apps/app/src/components/PageComment.tsx
  24. 9 0
      apps/app/src/components/PageControls/PageControls.tsx
  25. 4 4
      apps/app/src/components/PageCreateModal.jsx
  26. 5 1
      apps/app/src/components/PageEditor/PageEditor.tsx
  27. 9 20
      apps/app/src/components/PageEditor/Preview.module.scss
  28. 6 2
      apps/app/src/components/PageEditor/Preview.tsx
  29. 8 0
      apps/app/src/components/SearchPage/SearchResultContent.module.scss
  30. 13 13
      apps/app/src/components/SearchPage/SearchResultContent.tsx
  31. 4 0
      apps/app/src/components/ShareLinkPageView.tsx
  32. 2 1
      apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss
  33. 7 7
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  34. 21 147
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  35. 95 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx
  36. 1 1
      apps/app/src/components/Sidebar/Sidebar.tsx
  37. 1 1
      apps/app/src/components/Sidebar/Tag.tsx
  38. 2 0
      apps/app/src/interfaces/template.ts
  39. 3 5
      apps/app/src/pages/[[...path]].page.tsx
  40. 1 1
      apps/app/src/pages/me/[[...path]].page.tsx
  41. 2 5
      apps/app/src/pages/share/[[...path]].page.tsx
  42. 1 1
      apps/app/src/pages/tags.page.tsx
  43. 1 1
      apps/app/src/pages/trash.page.tsx
  44. 2 5
      apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts
  45. 2 2
      apps/app/src/server/middlewares/certify-shared-page-attachment/retrieve-valid-share-link.ts
  46. 1 1
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.ts
  47. 5 2
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.ts
  48. 0 5
      apps/app/src/styles/_layout.scss
  49. 1 0
      apps/app/src/styles/_mixins.scss
  50. 4 0
      apps/app/src/styles/mixins/_fluid-layout.scss
  51. 2 1
      apps/app/src/styles/organisms/_wiki.scss
  52. 1 1
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts
  53. 3 1
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts
  54. 1 2
      packages/core/scss/_flex-expand.scss
  55. 14 0
      packages/core/scss/bootstrap/_variables-dark.scss
  56. 22 0
      packages/core/scss/bootstrap/_variables.scss
  57. 2 1
      packages/core/scss/bootstrap/apply.scss
  58. 1 0
      packages/core/scss/bootstrap/init.scss
  59. 59 0
      packages/core/scss/bootstrap/mixins/_button-outline-variant.scss
  60. 3 0
      packages/core/scss/bootstrap/override/_badge.scss
  61. 21 0
      packages/core/scss/bootstrap/override/_buttons.scss
  62. 7 5
      packages/core/scss/bootstrap/theming/_buttons-dark.scss
  63. 17 0
      packages/core/scss/bootstrap/theming/_buttons-light.scss
  64. 3 1
      packages/core/scss/bootstrap/theming/apply-dark.scss
  65. 7 0
      packages/core/scss/bootstrap/theming/apply-light.scss
  66. 26 10
      packages/editor/src/components/CodeMirrorEditor/Toolbar/TextFormatTools.tsx
  67. 3 5
      packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx
  68. 8 0
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts
  69. 27 0
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/insert-markdown-elements.ts
  70. 35 0
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/insert-prefix.ts
  71. 0 1
      packages/editor/vite.config.ts
  72. 2 6
      packages/preset-themes/src/styles/default.scss
  73. 2 6
      packages/preset-themes/src/styles/mono-blue.scss

+ 0 - 5
packages/core/scss/bootstrap/_override.scss → apps/app/_obsolete/src/styles/_override.scss

@@ -99,11 +99,6 @@
 //   }
 // }
 
-// Badges
-.badge {
-  @extend .rounded-pill;
-}
-
 // //Modals
 // .modal-open {
 //   width: 100%;

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

@@ -464,7 +464,7 @@
       "label": "Template for children",
       "desc": "Applies only to the same level pages which the template exists"
     },
-    "decendants": {
+    "descendants": {
       "label": "Template for descendants",
       "desc": "Applies to all decendant pages"
     }

+ 1 - 1
apps/app/public/static/locales/ja_JP/commons.json

@@ -79,7 +79,7 @@
     "template": {
       "desc": "テンプレートページの作成/編集",
       "children": "同一階層テンプレート",
-      "decendants": "下位層テンプレート"
+      "descendants": "下位層テンプレート"
     }
   },
 

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

@@ -497,7 +497,7 @@
       "label": "同一階層テンプレート",
       "desc": "テンプレートページが存在する階層にのみ適用されます"
     },
-    "decendants": {
+    "descendants": {
       "label": "下位層テンプレート",
       "desc": "テンプレートページが存在する下位層のすべてのページに適用されます"
     }

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

@@ -451,7 +451,7 @@
 			"label": "子模板",
 			"desc": "仅应用于模板存在的同一级别页"
 		},
-		"decendants": {
+		"descendants": {
 			"label": "子代模板",
 			"desc": "适用于所有分散页"
 		}

+ 20 - 11
apps/app/src/client/services/layout.ts

@@ -9,20 +9,29 @@ export const useEditorModeClassName = (): string => {
   return `${getClassNamesByEditorMode().join(' ') ?? ''}`;
 };
 
-export const useLayoutFluidClassName = (expandContentWidth?: boolean | null): string => {
+const useDetermineExpandContent = (expandContentWidth?: boolean | null): boolean => {
   const { data: dataIsContainerFluid } = useIsContainerFluid();
 
   const isContainerFluidDefault = dataIsContainerFluid;
-  const isContainerFluid = expandContentWidth ?? isContainerFluidDefault;
-
-  return isContainerFluid ? 'growi-layout-fluid' : '';
+  return expandContentWidth ?? isContainerFluidDefault ?? false;
 };
 
-export const useLayoutFluidClassNameByPage = (initialPage?: IPage): string => {
-  const page = initialPage;
-  const expandContentWidth = page == null || !('expandContentWidth' in page)
-    ? null
-    : page.expandContentWidth;
-
-  return useLayoutFluidClassName(expandContentWidth);
+export const useShouldExpandContent = (data?: IPage | boolean | null): boolean => {
+  const expandContentWidth = (() => {
+    // when data is null
+    if (data == null) {
+      return null;
+    }
+    // when data is boolean
+    if (data === true || data === false) {
+      return data;
+    }
+    // when IPage does not have expandContentWidth
+    if (!('expandContentWidth' in data)) {
+      return null;
+    }
+    return data.expandContentWidth;
+  })();
+
+  return useDetermineExpandContent(expandContentWidth);
 };

+ 49 - 0
apps/app/src/client/services/use-on-template-button-clicked.ts

@@ -0,0 +1,49 @@
+import { useCallback, useState } from 'react';
+
+import { useRouter } from 'next/router';
+
+import { createPage, exist } from '~/client/services/page-operation';
+import { LabelType } from '~/interfaces/template';
+
+export const useOnTemplateButtonClicked = (
+    currentPagePath?: string,
+): {
+  onClickHandler: (label: LabelType) => Promise<void>,
+  isPageCreating: boolean
+} => {
+  const router = useRouter();
+  const [isPageCreating, setIsPageCreating] = useState(false);
+
+  const onClickHandler = useCallback(async(label: LabelType) => {
+    try {
+      setIsPageCreating(true);
+
+      const path = currentPagePath == null || currentPagePath === '/'
+        ? `/${label}`
+        : `${currentPagePath}/${label}`;
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+      // grant: currentPage?.grant || 1,
+      // grantUserGroupId: currentPage?.grantedGroup?._id,
+      };
+
+      const res = await exist(JSON.stringify([path]));
+      if (!res.pages[path]) {
+        await createPage(path, '', params);
+      }
+
+      router.push(`${path}#edit`);
+    }
+    catch (err) {
+      throw err;
+    }
+    finally {
+      setIsPageCreating(false);
+    }
+  }, [currentPagePath, router]);
+
+  return { onClickHandler, isPageCreating };
+};

+ 18 - 21
apps/app/src/components/Comments.tsx

@@ -55,7 +55,7 @@ export const Comments = (props: CommentsProps): JSX.Element => {
     // see: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe
     // > You can call observe() multiple times on the same MutationObserver
     // > to watch for changes to different parts of the DOM tree and/or different types of changes.
-  }, [onLoaded]);
+  }, [onLoadedDebounced]);
 
   const isTopPagePath = isTopPage(pagePath);
 
@@ -69,29 +69,26 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   };
 
   return (
-    <div className="page-comments-row mt-5 py-4 d-edit-none d-print-none">
-      <div className="container-lg">
-        <div id="page-comments-list" className="page-comments-list" ref={pageCommentParentRef}>
-          <PageComment
+    <div className="page-comments-row mt-5 py-4 border-top border-3 d-edit-none d-print-none">
+      <div id="page-comments-list" className="page-comments-list" ref={pageCommentParentRef}>
+        <PageComment
+          pageId={pageId}
+          pagePath={pagePath}
+          revision={revision}
+          currentUser={currentUser}
+          isReadOnly={false}
+        />
+      </div>
+      {!isDeleted && (
+        <div id="page-comment-write">
+          <CommentEditor
             pageId={pageId}
-            pagePath={pagePath}
-            revision={revision}
-            currentUser={currentUser}
-            isReadOnly={false}
-            titleAlign="left"
+            isForNewComment
+            onCommentButtonClicked={onCommentButtonClickHandler}
+            revisionId={revision._id}
           />
         </div>
-        {!isDeleted && (
-          <div id="page-comment-write">
-            <CommentEditor
-              pageId={pageId}
-              isForNewComment
-              onCommentButtonClicked={onCommentButtonClickHandler}
-              revisionId={revision._id}
-            />
-          </div>
-        )}
-      </div>
+      )}
     </div>
   );
 

+ 32 - 10
apps/app/src/components/Common/PageViewLayout.module.scss

@@ -1,11 +1,42 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
+@use '~/styles/mixins';
 @use '~/styles/variables' as var;
 
 
+$subnavigation-height: 50px;
+$page-view-layout-margin-top: 32px;
+
+.page-view-layout :global {
+  $page-content-footer-min-heigh: 130px;
+  min-height: calc(100vh - #{$subnavigation-height + $page-view-layout-margin-top + $page-content-footer-min-heigh});
+}
+
+// md/lg layout padding
 .page-view-layout :global {
-  min-height: calc(100vh - 48px - 250px); // 100vh - subnavigation height - page-comments-row minimum height
+  @include bs.media-breakpoint-between(md, xl) {
+    padding-left: var.$grw-sidebar-nav-width;
+  }
+}
+
+// container padding
+.page-view-layout :global,
+.footer-layout :global {
+  @include bs.media-breakpoint-up(lg) {
+    .container-lg {
+      --bs-gutter-x: 3rem;
+    }
+  }
+}
+
+// fluid layout
+.fluid-layout :global {
+  .grw-container-convertible {
+    @include mixins.fluid-layout();
+  }
+}
 
+.page-view-layout :global {
   .grw-side-contents-container {
     margin-bottom: 1rem;
 
@@ -17,20 +48,11 @@
   }
 }
 
-// md/lg layout padding
-.page-view-layout :global {
-  @include bs.media-breakpoint-between(md, xl) {
-    padding-left: var.$grw-sidebar-nav-width;
-  }
-}
-
 // sticky side contents
 .page-view-layout :global {
   .grw-side-contents-sticky-container {
     position: sticky;
 
-    $subnavigation-height: 50px;
-    $page-view-layout-margin-top: 32px;
     $page-path-nav-height: 99px;
     top: calc($subnavigation-height + $page-view-layout-margin-top + $page-path-nav-height + 4px);
   }

+ 13 - 5
apps/app/src/components/Common/PageViewLayout.tsx

@@ -2,27 +2,35 @@ import type { ReactNode } from 'react';
 
 import styles from './PageViewLayout.module.scss';
 
+const pageViewLayoutClass = styles['page-view-layout'] ?? '';
+const footerLayoutClass = styles['footer-layout'] ?? '';
+const _fluidLayoutClass = styles['fluid-layout'] ?? '';
+
 type Props = {
   children?: ReactNode,
   headerContents?: ReactNode,
   sideContents?: ReactNode,
   footerContents?: ReactNode,
+  expandContentWidth?: boolean,
 }
 
 export const PageViewLayout = (props: Props): JSX.Element => {
   const {
     children, headerContents, sideContents, footerContents,
+    expandContentWidth,
   } = props;
 
+  const fluidLayoutClass = expandContentWidth ? _fluidLayoutClass : '';
+
   return (
     <>
-      <div id="main" className={`main ${styles['page-view-layout']}`}>
-        <div id="content-main" className="content-main container-lg grw-container-convertible">
+      <div id="main" className={`main ${pageViewLayoutClass} ${fluidLayoutClass} flex-expand-vert`}>
+        <div id="content-main" className="content-main container-lg grw-container-convertible flex-expand-vert">
           { headerContents != null && headerContents }
           { sideContents != null
             ? (
-              <div className="d-flex gap-3">
-                <div className="flex-grow-1 flex-basis-0 mw-0">
+              <div className="flex-expand-horiz gap-3">
+                <div className="flex-expand-vert flex-basis-0 mw-0">
                   {children}
                 </div>
                 <div className="grw-side-contents-container col-lg-3  d-edit-none d-print-none" data-vrt-blackout-side-contents>
@@ -40,7 +48,7 @@ export const PageViewLayout = (props: Props): JSX.Element => {
       </div>
 
       { footerContents != null && (
-        <footer className="footer d-edit-none">
+        <footer className={`footer d-edit-none ${footerLayoutClass} ${fluidLayoutClass}`}>
           {footerContents}
         </footer>
       ) }

+ 0 - 76
apps/app/src/components/CreateTemplateModal.jsx

@@ -1,76 +0,0 @@
-import React from 'react';
-
-import { pathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
-import urljoin from 'url-join';
-
-const CreateTemplateModal = (props) => {
-  const { t } = useTranslation();
-  const { path } = props;
-
-  const parentPath = pathUtils.addTrailingSlash(path);
-
-  function generateUrl(label) {
-    return encodeURI(urljoin(parentPath, label, '#edit'));
-  }
-
-  /**
-   * @param {string} target Which hierarchy to create [children, decendants]
-   */
-  function renderTemplateCard(target, label) {
-    return (
-      <div className="card card-select-template">
-        <div className="card-header">{ t(`template.${target}.label`) }</div>
-        <div className="card-body">
-          <p className="text-center"><code>{label}</code></p>
-          <p className="form-text text-muted text-center"><small>{t(`template.${target}.desc`) }</small></p>
-        </div>
-        <div className="card-footer text-center">
-          <a
-            data-testid={`template-button-${target}`}
-            href={generateUrl(label)}
-            className="btn btn-sm btn-primary"
-            id={`template-button-${target}`}
-          >
-            { t('Edit') }
-          </a>
-        </div>
-      </div>
-    );
-  }
-
-  return (
-    <Modal isOpen={props.isOpen} toggle={props.onClose} data-testid="page-template-modal">
-      <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
-        {t('template.modal_label.Create/Edit Template Page')}
-      </ModalHeader>
-      <ModalBody>
-        <div>
-          <label className="form-label mb-4">
-            <code>{parentPath}</code><br />
-            { t('template.modal_label.Create template under') }
-          </label>
-          <div className="row row-cols-2">
-            <div className="col">
-              {renderTemplateCard('children', '_template')}
-            </div>
-            <div className="col">
-              {renderTemplateCard('decendants', '__template')}
-            </div>
-          </div>
-        </div>
-      </ModalBody>
-    </Modal>
-
-  );
-};
-
-CreateTemplateModal.propTypes = {
-  path: PropTypes.string.isRequired,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-};
-
-export default CreateTemplateModal;

+ 101 - 0
apps/app/src/components/CreateTemplateModal.tsx

@@ -0,0 +1,101 @@
+import React, { useCallback } from 'react';
+
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+
+import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
+import { toastError } from '~/client/util/toastr';
+import { TargetType, LabelType } from '~/interfaces/template';
+
+
+type TemplateCardProps = {
+  target: TargetType;
+  label: LabelType;
+  isPageCreating: boolean;
+  onClickHandler: () => void;
+};
+
+const TemplateCard: React.FC<TemplateCardProps> = ({
+  target, label, isPageCreating, onClickHandler,
+}) => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="card card-select-template">
+      <div className="card-header">{t(`template.${target}.label`)}</div>
+      <div className="card-body">
+        <p className="text-center"><code>{label}</code></p>
+        <p className="form-text text-muted text-center"><small>{t(`template.${target}.desc`)}</small></p>
+      </div>
+      <div className="card-footer text-center">
+        <button
+          disabled={isPageCreating}
+          data-testid={`template-button-${target}`}
+          className="btn btn-sm btn-primary"
+          id={`template-button-${target}`}
+          onClick={onClickHandler}
+          type="button"
+        >
+          {t('Edit')}
+        </button>
+      </div>
+    </div>
+  );
+};
+
+type CreateTemplateModalProps = {
+  path: string;
+  isOpen: boolean;
+  onClose: () => void;
+};
+
+export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
+  path, isOpen, onClose,
+}) => {
+  const { t } = useTranslation();
+
+  const { onClickHandler: onClickTemplateButton, isPageCreating } = useOnTemplateButtonClicked(path);
+
+  const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
+    try {
+      await onClickTemplateButton(label);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [onClickTemplateButton]);
+
+  const parentPath = pathUtils.addTrailingSlash(path);
+
+  const renderTemplateCard = (target: TargetType, label: LabelType) => (
+    <div className="col">
+      <TemplateCard
+        target={target}
+        label={label}
+        isPageCreating={isPageCreating}
+        onClickHandler={() => onClickTemplateButtonHandler(label)}
+      />
+    </div>
+  );
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
+      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">
+        {t('template.modal_label.Create/Edit Template Page')}
+      </ModalHeader>
+      <ModalBody>
+        <div>
+          <label className="form-label mb-4">
+            <code>{parentPath}</code><br />
+            {t('template.modal_label.Create template under')}
+          </label>
+          <div className="row row-cols-2">
+            {renderTemplateCard('children', '_template')}
+            {renderTemplateCard('descendants', '__template')}
+          </div>
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};

+ 15 - 34
apps/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -1,19 +1,13 @@
-import React, {
-  FC, useRef,
-} from 'react';
+import React, { FC } from 'react';
 
-import type { IUser, IPage, HasObjectId } from '@growi/core';
+import type { HasObjectId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { DropdownItem } from 'reactstrap';
 
-import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { SupportedTargetModel } from '~/interfaces/activity';
 import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
-// Change the display for each targetmodel
-import PageModelNotification from './PageNotification/PageModelNotification';
-import UserModelNotification from './PageNotification/UserModelNotification';
+import { useModelNotification } from './PageNotification';
 
 interface Props {
   notification: IInAppNotification & HasObjectId
@@ -26,7 +20,14 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
 
   const { notification } = props;
 
-  const notificationRef = useRef<IInAppNotificationOpenable>(null);
+  const modelNotificationUtils = useModelNotification(notification);
+
+  const Notification = modelNotificationUtils?.Notification;
+  const publishOpen = modelNotificationUtils?.publishOpen;
+
+  if (Notification == null || publishOpen == null) {
+    return <></>;
+  }
 
   const clickHandler = async(notification: IInAppNotification & HasObjectId): Promise<void> => {
     if (notification.status === InAppNotificationStatuses.STATUS_UNOPENED) {
@@ -34,10 +35,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       await apiv3Post('/in-app-notification/open', { id: notification._id });
     }
 
-    const currentInstance = notificationRef.current;
-    if (currentInstance != null) {
-      currentInstance.open();
-    }
+    publishOpen();
   };
 
   const renderActionUserPictures = (): JSX.Element => {
@@ -61,14 +59,6 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
 
   const isDropdownItem = props.type === 'dropdown-item';
 
-  const isPageNotification = (notification: IInAppNotification): notification is IInAppNotification<IPage> => {
-    return notification.targetModel === SupportedTargetModel.MODEL_PAGE;
-  };
-
-  const isUserNotification = (notification: IInAppNotification): notification is IInAppNotification<IUser> => {
-    return notification.targetModel === SupportedTargetModel.MODEL_USER;
-  };
-
   // determine tag
   const TagElem = isDropdownItem
     ? DropdownItem
@@ -86,18 +76,9 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
         >
         </span>
         {renderActionUserPictures()}
-        {isPageNotification(notification) && (
-          <PageModelNotification
-            ref={notificationRef}
-            notification={notification}
-          />
-        )}
-        {isUserNotification(notification) && (
-          <UserModelNotification
-            ref={notificationRef}
-            notification={notification}
-          />
-        )}
+
+        <Notification />
+
       </div>
     </TagElem>
   );

+ 2 - 11
apps/app/src/components/InAppNotification/PageNotification/ModelNotification.tsx

@@ -1,9 +1,8 @@
-import React, { FC, useImperativeHandle } from 'react';
+import React, { FC } from 'react';
 
 import type { HasObjectId } from '@growi/core';
 import { PagePathLabel } from '@growi/ui/dist/components';
 
-import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 import FormattedDistanceDate from '../../FormattedDistanceDate';
@@ -13,21 +12,13 @@ type Props = {
   actionMsg: string
   actionIcon: string
   actionUsers: string
-  publishOpen:() => void
-  ref: React.ForwardedRef<IInAppNotificationOpenable>
 };
 
 export const ModelNotification: FC<Props> = (props) => {
   const {
-    notification, actionMsg, actionIcon, actionUsers, publishOpen, ref,
+    notification, actionMsg, actionIcon, actionUsers,
   } = props;
 
-  useImperativeHandle(ref, () => ({
-    open() {
-      publishOpen();
-    },
-  }));
-
   return (
     <div className="p-2 overflow-hidden">
       <div className="text-truncate">

+ 34 - 25
apps/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -1,29 +1,27 @@
 import React, {
-  forwardRef, ForwardRefRenderFunction, useCallback,
+  FC, useCallback,
 } from 'react';
 
 import type { IPage, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 
-import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
 
 import { ModelNotification } from './ModelNotification';
-import { useActionMsgAndIconForPageModelNotification } from './useActionAndMsg';
+import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
 
-interface Props {
-  notification: IInAppNotification<IPage> & HasObjectId
+export interface ModelNotificationUtils {
+  Notification: FC
+  publishOpen: () => void
 }
 
-const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, Props> = (props: Props, ref) => {
-
-  const { notification } = props;
-
-  const { actionMsg, actionIcon } = useActionMsgAndIconForPageModelNotification(notification);
+export const usePageModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
 
   const router = useRouter();
+  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
 
   const getActionUsers = useCallback(() => {
     const latestActionUsers = notification.actionUsers.slice(0, 3);
@@ -46,31 +44,42 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
     return actionedUsers;
   }, [notification.actionUsers]);
 
+  const isPageModelNotification = (notification: IInAppNotification & HasObjectId): notification is IInAppNotification<IPage> & HasObjectId => {
+    return notification.targetModel === SupportedTargetModel.MODEL_PAGE;
+  };
+
+  if (!isPageModelNotification(notification)) {
+    return null;
+  }
+
   const actionUsers = getActionUsers();
 
-  // publish open()
+  notification.parsedSnapshot = pageSerializers.parseSnapshot(notification.snapshot);
+
+  const Notification = () => {
+    return (
+      <ModelNotification
+        notification={notification}
+        actionMsg={actionMsg}
+        actionIcon={actionIcon}
+        actionUsers={actionUsers}
+      />
+    );
+  };
+
   const publishOpen = () => {
     if (notification.target != null) {
       // jump to target page
-      const targetPagePath = notification.target.path;
+      const targetPagePath = (notification.target as IPage).path;
       if (targetPagePath != null) {
         router.push(targetPagePath);
       }
     }
   };
 
-  notification.parsedSnapshot = pageSerializers.parseSnapshot(notification.snapshot);
+  return {
+    Notification,
+    publishOpen,
+  };
 
-  return (
-    <ModelNotification
-      notification={notification}
-      actionMsg={actionMsg}
-      actionIcon={actionIcon}
-      actionUsers={actionUsers}
-      publishOpen={publishOpen}
-      ref={ref}
-    />
-  );
 };
-
-export default forwardRef(PageModelNotification);

+ 30 - 26
apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx

@@ -1,45 +1,49 @@
-import React, {
-  forwardRef, ForwardRefRenderFunction,
-} from 'react';
+import React from 'react';
 
 import type { IUser, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 
-import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 import { ModelNotification } from './ModelNotification';
-import { useActionMsgAndIconForUserModelNotification } from './useActionAndMsg';
+import { ModelNotificationUtils } from './PageModelNotification';
+import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
-interface Props {
-  notification: IInAppNotification<IUser> & HasObjectId
-}
 
-const UserModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, Props> = (props: Props, ref) => {
+export const useUserModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
 
-  const { notification } = props;
+  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
+  const router = useRouter();
 
-  const { actionMsg, actionIcon } = useActionMsgAndIconForUserModelNotification(notification);
+  const isUserModelNotification = (notification: IInAppNotification & HasObjectId): notification is IInAppNotification<IUser> & HasObjectId => {
+    return notification.targetModel === SupportedTargetModel.MODEL_USER;
+  };
 
-  const router = useRouter();
+  if (!isUserModelNotification(notification)) {
+    return null;
+  }
+
+  const actionUsers = notification.target.username;
+
+  const Notification = () => {
+    return (
+      <ModelNotification
+        notification={notification}
+        actionMsg={actionMsg}
+        actionIcon={actionIcon}
+        actionUsers={actionUsers}
+      />
+    );
+  };
 
-  // publish open()
   const publishOpen = () => {
     router.push('/admin/users');
   };
 
-  const actionUsers = notification.target.username;
+  return {
+    Notification,
+    publishOpen,
+  };
 
-  return (
-    <ModelNotification
-      notification={notification}
-      actionMsg={actionMsg}
-      actionIcon={actionIcon}
-      actionUsers={actionUsers}
-      publishOpen={publishOpen}
-      ref={ref}
-    />
-  );
 };
-
-export default forwardRef(UserModelNotification);

+ 19 - 0
apps/app/src/components/InAppNotification/PageNotification/index.tsx

@@ -0,0 +1,19 @@
+import type { HasObjectId } from '@growi/core';
+
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
+
+
+import { usePageModelNotification, type ModelNotificationUtils } from './PageModelNotification';
+import { useUserModelNotification } from './UserModelNotification';
+
+
+export const useModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
+
+  const pageModelNotificationUtils = usePageModelNotification(notification);
+  const userModelNotificationUtils = useUserModelNotification(notification);
+
+  const modelNotificationUtils = pageModelNotificationUtils ?? userModelNotificationUtils;
+
+
+  return modelNotificationUtils;
+};

+ 2 - 19
apps/app/src/components/InAppNotification/PageNotification/useActionAndMsg.ts

@@ -1,4 +1,4 @@
-import type { IUser, IPage, HasObjectId } from '@growi/core';
+import type { HasObjectId } from '@growi/core';
 
 import { SupportedAction } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
@@ -8,7 +8,7 @@ export type ActionMsgAndIconType = {
   actionIcon: string
 }
 
-export const useActionMsgAndIconForPageModelNotification = (notification: IInAppNotification<IPage> & HasObjectId): ActionMsgAndIconType => {
+export const useActionMsgAndIconForModelNotification = (notification: IInAppNotification & HasObjectId): ActionMsgAndIconType => {
   const actionType: string = notification.action;
   let actionMsg: string;
   let actionIcon: string;
@@ -66,23 +66,6 @@ export const useActionMsgAndIconForPageModelNotification = (notification: IInApp
       actionMsg = 'commented on';
       actionIcon = 'icon-bubble';
       break;
-    default:
-      actionMsg = '';
-      actionIcon = '';
-  }
-
-  return {
-    actionMsg,
-    actionIcon,
-  };
-};
-
-export const useActionMsgAndIconForUserModelNotification = (notification: IInAppNotification<IUser> & HasObjectId): ActionMsgAndIconType => {
-  const actionType: string = notification.action;
-  let actionMsg: string;
-  let actionIcon: string;
-
-  switch (actionType) {
     case SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST:
       actionMsg = 'requested registration approval';
       actionIcon = 'icon-bubble';

+ 7 - 7
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -11,11 +11,12 @@ import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
   useCurrentPathname,
-  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId, useIsContainerFluid,
+  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores/context';
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, type IPageForPageDuplicateModal,
@@ -28,10 +29,9 @@ import { mutatePageTree } from '~/stores/page-listing';
 import {
   useEditorMode, useIsAbleToShowPageManagement,
   useIsAbleToChangeEditorMode,
-  useSelectedGrant,
 } from '~/stores/ui';
 
-import CreateTemplateModal from '../CreateTemplateModal';
+import { CreateTemplateModal } from '../CreateTemplateModal';
 import AttachmentIcon from '../Icons/AttachmentIcon';
 import HistoryIcon from '../Icons/HistoryIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
@@ -196,8 +196,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
-  const { data: isContainerFluid } = useIsContainerFluid();
-  const { data: grantData } = useSelectedGrant();
+
+  const shouldExpandContent = useShouldExpandContent(currentPage);
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
@@ -299,7 +299,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     <>
       <div
         className={`${styles['grw-contextual-sub-navigation']}
-          d-flex align-items-center justify-content-end px-2 py-1 gap-2 gap-md-4 d-print-none
+          d-flex align-items-center justify-content-end px-2 px-sm-3 px-md-4 py-1 gap-2 gap-md-4 d-print-none
         `}
         data-testid="grw-contextual-sub-nav"
       >
@@ -309,7 +309,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             revisionId={revisionId}
             shareLinkId={shareLinkId}
             path={path ?? currentPathname} // If the page is empty, "path" is undefined
-            expandContentWidth={currentPage?.expandContentWidth ?? isContainerFluid}
+            expandContentWidth={shouldExpandContent}
             disableSeenUserInfoPopover={isSharedUser}
             showPageControlDropdown={isAbleToShowPageManagement}
             additionalMenuItemRenderer={additionalMenuItemsRenderer}

+ 2 - 2
apps/app/src/components/Navbar/PageEditorModeManager.module.scss

@@ -31,7 +31,7 @@
 // == Colors
 @include bs.color-mode(light) {
   .grw-page-editor-mode-manager :global {
-    .btn-outline-primary {
+    .btn {
       $color: var(--grw-page-editor-mode-manager-btn-color, var(--grw-primary-700));
       $bg: var(--grw-page-editor-mode-manager-btn-bg, var(--grw-primary-100));
       $bg-rgb: var(--grw-page-editor-mode-manager-btn-bg-rgb, var(--grw-primary-100-rgb));
@@ -51,7 +51,7 @@
 }
 @include bs.color-mode(dark) {
   .grw-page-editor-mode-manager :global {
-    .btn-outline-primary {
+    .btn {
       $color: var(--grw-page-editor-mode-manager-btn-color, var(--grw-primary-300));
       $bg: var(--grw-page-editor-mode-manager-btn-bg, var(--grw-primary-800));
       $bg-rgb: var(--grw-page-editor-mode-manager-btn-bg-rgb, var(--grw-primary-800-rgb));

+ 1 - 1
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -22,7 +22,7 @@ const PageEditorModeButton = React.memo((props: PageEditorModeButtonProps) => {
     currentEditorMode, isBtnDisabled, editorMode, children, onClick,
   } = props;
 
-  const classNames = ['btn btn-outline-primary py-1 px-2 d-flex align-items-center justify-content-center'];
+  const classNames = ['btn py-1 px-2 d-flex align-items-center justify-content-center'];
   if (currentEditorMode === editorMode) {
     classNames.push('active');
   }

+ 22 - 10
apps/app/src/components/Page/PageView.tsx

@@ -6,6 +6,7 @@ import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import dynamic from 'next/dynamic';
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import {
@@ -70,6 +71,8 @@ export const PageView = (props: Props): JSX.Element => {
   const isNotFound = isNotFoundMeta || page?.revision == null;
   const isUsersHomepagePath = isUsersHomepage(pagePath);
 
+  const shouldExpandContent = useShouldExpandContent(page);
+
 
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
@@ -112,14 +115,6 @@ export const PageView = (props: Props): JSX.Element => {
   const footerContents = !isIdenticalPathPage && !isNotFound
     ? (
       <>
-        <div id="comments-container" ref={commentsContainerRef}>
-          <Comments
-            pageId={page._id}
-            pagePath={pagePath}
-            revision={page.revision}
-            onLoaded={() => setCommentsLoaded(true)}
-          />
-        </div>
         {(isUsersHomepagePath && page.creator != null) && (
           <UsersHomepageFooter creatorId={page.creator._id} />
         )}
@@ -139,16 +134,33 @@ export const PageView = (props: Props): JSX.Element => {
     return (
       <>
         <PageContentsUtilities />
-        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+
+        <div className="flex-expand-vert justify-content-between">
+          <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+
+          { !isIdenticalPathPage && !isNotFound && (
+            <div id="comments-container" ref={commentsContainerRef}>
+              <Comments
+                pageId={page._id}
+                pagePath={pagePath}
+                revision={page.revision}
+                onLoaded={() => setCommentsLoaded(true)}
+              />
+            </div>
+          ) }
+        </div>
       </>
     );
   };
 
+  const mobileClass = isMobile ? styles['page-mobile'] : '';
+
   return (
     <PageViewLayout
       headerContents={headerContents}
       sideContents={sideContents}
       footerContents={footerContents}
+      expandContentWidth={shouldExpandContent}
     >
       <PageAlerts />
 
@@ -156,7 +168,7 @@ export const PageView = (props: Props): JSX.Element => {
       {specialContents == null && (
         <>
           {(isUsersHomepagePath && page?.creator != null) && <UserInfo author={page.creator} />}
-          <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
+          <div className={`flex-expand-vert ${mobileClass}`}>
             <Contents />
           </div>
         </>

+ 49 - 55
apps/app/src/components/PageComment.tsx

@@ -31,14 +31,13 @@ export type PageCommentProps = {
   revision: string | IRevisionHasId,
   currentUser: any,
   isReadOnly: boolean,
-  titleAlign?: 'center' | 'left' | 'right',
 }
 
 export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps): JSX.Element => {
 
   const {
     rendererOptions: rendererOptionsByProps,
-    pageId, pagePath, revision, currentUser, isReadOnly, titleAlign,
+    pageId, pagePath, revision, currentUser, isReadOnly,
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
@@ -112,9 +111,6 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
     return <></>;
   }
 
-  let commentTitleClasses = 'border-bottom py-3 mb-3';
-  commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
-
   const rendererOptions = rendererOptionsByProps ?? rendererOptionsForCurrentPage;
 
   if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
@@ -156,58 +152,56 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
 
   return (
     <div 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}>
-                  {commentElement(comment)}
-                  {hasReply && replyCommentsElement(allReplies[comment._id])}
-                  {(!isReadOnly && !showEditorIds.has(comment._id)) && (
-                    <div className="d-flex flex-row-reverse">
-                      <NotAvailableForGuest>
-                        <NotAvailableForReadOnlyUser>
-                          <Button
-                            data-testid="comment-reply-button"
-                            outline
-                            color="secondary"
-                            size="sm"
-                            className="btn-comment-reply"
-                            onClick={() => onReplyButtonClickHandler(comment._id)}
-                          >
-                            <i className="icon-fw icon-action-undo"></i> Reply
-                          </Button>
-                        </NotAvailableForReadOnlyUser>
-                      </NotAvailableForGuest>
-                    </div>
-                  )}
-                  {(!isReadOnly && showEditorIds.has(comment._id)) && (
-                    <CommentEditor
-                      pageId={pageId}
-                      replyTo={comment._id}
-                      onCancelButtonClicked={() => {
-                        removeShowEditorId(comment._id);
-                      }}
-                      onCommentButtonClicked={() => onCommentButtonClickHandler(comment._id)}
-                      revisionId={revisionId}
-                    />
-                  )}
-                </div>
-              );
-
-            })}
-          </div>
+      <div className="page-comments">
+        <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}>
+                {commentElement(comment)}
+                {hasReply && replyCommentsElement(allReplies[comment._id])}
+                {(!isReadOnly && !showEditorIds.has(comment._id)) && (
+                  <div className="d-flex flex-row-reverse">
+                    <NotAvailableForGuest>
+                      <NotAvailableForReadOnlyUser>
+                        <Button
+                          data-testid="comment-reply-button"
+                          outline
+                          color="secondary"
+                          size="sm"
+                          className="btn-comment-reply"
+                          onClick={() => onReplyButtonClickHandler(comment._id)}
+                        >
+                          <i className="icon-fw icon-action-undo"></i> Reply
+                        </Button>
+                      </NotAvailableForReadOnlyUser>
+                    </NotAvailableForGuest>
+                  </div>
+                )}
+                {(!isReadOnly && showEditorIds.has(comment._id)) && (
+                  <CommentEditor
+                    pageId={pageId}
+                    replyTo={comment._id}
+                    onCancelButtonClicked={() => {
+                      removeShowEditorId(comment._id);
+                    }}
+                    onCommentButtonClicked={() => onCommentButtonClickHandler(comment._id)}
+                    revisionId={revisionId}
+                  />
+                )}
+              </div>
+            );
+
+          })}
         </div>
       </div>
+
       {!isReadOnly && (
         <DeleteCommentModal
           isShown={isDeleteConfirmModalShown}

+ 9 - 0
apps/app/src/components/PageControls/PageControls.tsx

@@ -16,6 +16,7 @@ import { toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
 import { EditorMode, useEditorMode } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
@@ -32,6 +33,9 @@ import SubscribeButton from './SubscribeButton';
 
 import styles from './PageControls.module.scss';
 
+const logger = loggerFactory('growi:components/PageControls');
+
+
 type TagsProps = {
   onClickEditTagsButton: () => void,
 }
@@ -199,6 +203,11 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
     if (onClickSwitchContentWidth == null || (isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
+      logger.warn('Could not switch content width', {
+        onClickSwitchContentWidth: onClickSwitchContentWidth == null ? 'null' : 'not null',
+        isGuestUser,
+        isReadOnlyUser,
+      });
       return;
     }
     if (!isIPageInfoForEntity(pageInfo)) {

+ 4 - 4
apps/app/src/components/PageCreateModal.jsx

@@ -281,16 +281,16 @@ const PageCreateModal = () => {
               <DropdownToggle id="template-type" caret>
                 {template == null && t('template.option_label.select')}
                 {template === 'children' && t('template.children.label')}
-                {template === 'decendants' && t('template.decendants.label')}
+                {template === 'descendants' && t('template.descendants.label')}
               </DropdownToggle>
               <DropdownMenu>
                 <DropdownItem onClick={() => onChangeTemplateHandler('children')}>
                   {t('template.children.label')} (_template)<br className="d-block d-md-none" />
                   <small className="text-muted text-wrap">- {t('template.children.desc')}</small>
                 </DropdownItem>
-                <DropdownItem onClick={() => onChangeTemplateHandler('decendants')}>
-                  {t('template.decendants.label')} (__template) <br className="d-block d-md-none" />
-                  <small className="text-muted">- {t('template.decendants.desc')}</small>
+                <DropdownItem onClick={() => onChangeTemplateHandler('descendants')}>
+                  {t('template.descendants.label')} (__template) <br className="d-block d-md-none" />
+                  <small className="text-muted">- {t('template.descendants.desc')}</small>
                 </DropdownItem>
               </DropdownMenu>
             </UncontrolledButtonDropdown>

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

@@ -16,6 +16,7 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
@@ -57,11 +58,11 @@ import loggerFactory from '~/utils/logger';
 // import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 // import { ConflictDiffModal } from './ConflictDiffModal';
 // import Editor from './Editor';
+import EditorNavbarBottom from './EditorNavbarBottom';
 import Preview from './Preview';
 import scrollSyncHelper from './ScrollSyncHelper';
 
 import '@growi/editor/dist/style.css';
-import EditorNavbarBottom from './EditorNavbarBottom';
 
 
 const logger = loggerFactory('growi:PageEditor');
@@ -127,6 +128,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
 
+  const shouldExpandContent = useShouldExpandContent(currentPage);
+
   const saveOrUpdate = useSaveOrUpdate();
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
@@ -597,6 +600,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             rendererOptions={rendererOptions}
             markdown={markdownToPreview}
             pagePath={currentPagePath}
+            expandContentWidth={shouldExpandContent}
             // TODO: implement
             // refs: https://redmine.weseek.co.jp/issues/126519
             // onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}

+ 9 - 20
apps/app/src/components/PageEditor/Preview.module.scss

@@ -1,29 +1,18 @@
-@use '~/styles/variables' as var;
 @use '~/styles/mixins';
 
-@include mixins.editing(true) {
-  .page-editor-preview-body :global {
+.page-editor-preview-body :global {
+  .wiki {
+    max-width: 980px;
+    margin: 0 auto;
   }
 }
 
 // modify width for fluid layout
-@include mixins.editing(true) {
-  .dynamic-layout-root:not(.growi-layout-fluid) {
-    :local {
-      .page-editor-preview-body :global {
-        .wiki {
-          max-width: 980px;
-          margin: 0 auto;
-        }
-      }
-    }
-  }
-  .dynamic-layout-root.growi-layout-fluid {
-    :local {
-      .page-editor-preview-body :global {
-        .wiki {
-          margin: 0 auto;
-        }
+.page-editor-preview-body {
+  &:global {
+    &.fluid-layout {
+      .wiki {
+        @include mixins.fluid-layout();
       }
     }
   }

+ 6 - 2
apps/app/src/components/PageEditor/Preview.tsx

@@ -9,13 +9,14 @@ import RevisionRenderer from '../Page/RevisionRenderer';
 
 import styles from './Preview.module.scss';
 
-const moduleClass = styles['page-editor-preview-body'];
+const moduleClass = styles['page-editor-preview-body'] ?? '';
 
 
 type Props = {
   rendererOptions: RendererOptions,
   markdown?: string,
   pagePath?: string | null,
+  expandContentWidth?: boolean,
   onScroll?: (scrollTop: number) => void,
 }
 
@@ -24,11 +25,14 @@ const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>):
   const {
     rendererOptions,
     markdown, pagePath,
+    expandContentWidth,
   } = props;
 
+  const fluidLayoutClass = expandContentWidth ? 'fluid-layout' : '';
+
   return (
     <div
-      className={`${moduleClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
+      className={`${moduleClass} ${fluidLayoutClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
       ref={ref}
       onScroll={(event: SyntheticEvent<HTMLDivElement>) => {
         if (props.onScroll != null) {

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

@@ -1,2 +1,10 @@
+@use '~/styles/mixins';
+
 .search-result-content :global {
 }
+
+.fluid-layout :global {
+  .grw-container-convertible {
+    @include mixins.fluid-layout();
+  }
+}

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

@@ -10,12 +10,12 @@ import { animateScroll } from 'react-scroll';
 import { DropdownItem } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
-import { useLayoutFluidClassName } from '~/client/services/layout';
+import { useShouldExpandContent } from '~/client/services/layout';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/toastr';
 import type { IPageWithSearchMeta } from '~/interfaces/search';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import { useCurrentUser, useIsContainerFluid } from '~/stores/context';
+import { useCurrentUser } from '~/stores/context';
 import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
@@ -32,9 +32,10 @@ import type { PageContentFooterProps } from '../PageContentFooter';
 import styles from './SearchResultContent.module.scss';
 
 const moduleClass = styles['search-result-content'];
+const _fluidLayoutClass = styles['fluid-layout'];
 
 
-const SubNavButtons = dynamic(() => import('../PageControls').then(mod => mod.PageControls), { ssr: false });
+const PageControls = dynamic(() => import('../PageControls').then(mod => mod.PageControls), { ssr: false });
 const RevisionLoader = dynamic<RevisionLoaderProps>(() => import('../Page/RevisionLoader').then(mod => mod.RevisionLoader), { ssr: false });
 const PageComment = dynamic<PageCommentProps>(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
 const PageContentFooter = dynamic<PageContentFooterProps>(() => import('../PageContentFooter').then(mod => mod.PageContentFooter), { ssr: false });
@@ -123,12 +124,8 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: rendererOptions } = useSearchResultOptions(pageWithMeta.data.path, highlightKeywords);
   const { data: currentUser } = useCurrentUser();
-  const { data: isContainerFluid } = useIsContainerFluid();
 
-  const [isExpandContentWidth, setIsExpandContentWidth] = useState(page.expandContentWidth);
-
-  // TODO: determine className by the 'expandContentWidth' from the updated page
-  const growiLayoutFluidClass = useLayoutFluidClassName(isExpandContentWidth);
+  const shouldExpandContent = useShouldExpandContent(page);
 
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -176,7 +173,8 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
     await updateContentWidth(pageId, value);
-    setIsExpandContentWidth(value);
+
+    // TODO: revalidate page data and update shouldExpandContent
   }, []);
 
   const RightComponent = useCallback(() => {
@@ -188,11 +186,11 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
     return (
       <div className="d-flex flex-column align-items-end justify-content-center px-2 py-1">
-        <SubNavButtons
+        <PageControls
           pageId={page._id}
           revisionId={revisionId}
           path={page.path}
-          expandContentWidth={isExpandContentWidth ?? isContainerFluid}
+          expandContentWidth={shouldExpandContent}
           showPageControlDropdown={showPageControlDropdown}
           forceHideMenuItems={forceHideMenuItems}
           additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
@@ -203,16 +201,18 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
         />
       </div>
     );
-  }, [page, isExpandContentWidth, showPageControlDropdown, forceHideMenuItems, isContainerFluid,
+  }, [page, shouldExpandContent, showPageControlDropdown, forceHideMenuItems,
       duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, switchContentWidthHandler]);
 
   const isRenderable = page != null && rendererOptions != null;
 
+  const fluidLayoutClass = shouldExpandContent ? _fluidLayoutClass : '';
+
   return (
     <div
       key={page._id}
       data-testid="search-result-content"
-      className={`dynamic-layout-root ${growiLayoutFluidClass} ${moduleClass}`}
+      className={`dynamic-layout-root ${moduleClass} ${fluidLayoutClass}`}
     >
       <RightComponent />
 

+ 4 - 0
apps/app/src/components/ShareLinkPageView.tsx

@@ -3,6 +3,7 @@ import React, { useMemo } from 'react';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import dynamic from 'next/dynamic';
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
@@ -44,6 +45,8 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
 
   const { data: viewOptions } = useViewOptions();
 
+  const shouldExpandContent = useShouldExpandContent(page);
+
   const isNotFound = isNotFoundMeta || page == null || shareLink == null;
 
   const specialContents = useMemo(() => {
@@ -93,6 +96,7 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
     <PageViewLayout
       headerContents={headerContents}
       sideContents={sideContents}
+      expandContentWidth={shouldExpandContent}
     >
       { specialContents }
       { specialContents == null && (

+ 2 - 1
apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss

@@ -40,13 +40,14 @@
   // set width for truncation
   $grw-page-controls-width: 226px;
   $grw-page-editor-mode-manager-width: 90px;
-  $grw-contextual-subnavigation-padding-right: 8px;
+  $grw-contextual-subnavigation-padding-right: 12px;
   $gap: 8px;
   width: calc(100vw - #{$grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
 
   @include bs.media-breakpoint-up(md) {
     $grw-page-editor-mode-manager-width: 140px;
     $gap: 24px;
+    $grw-contextual-subnavigation-padding-right: 24px;
     width: calc(100vw - #{var.$grw-sidebar-nav-width + $grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
   }
 }

+ 7 - 7
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -2,12 +2,13 @@ import React from 'react';
 
 import { useTranslation } from 'react-i18next';
 
+import { LabelType } from '~/interfaces/template';
+
 type DropendMenuProps = {
   todaysPath: string,
   onClickCreateNewPageButtonHandler: () => Promise<void>
   onClickCreateTodaysButtonHandler: () => Promise<void>
-  onClickTemplateForChildrenButtonHandler: () => Promise<void>
-  onClickTemplateForDescendantsButtonHandler: () => Promise<void>
+  onClickTemplateButtonHandler: (label: LabelType) => Promise<void>
 }
 
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
@@ -15,8 +16,7 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
     todaysPath,
     onClickCreateNewPageButtonHandler,
     onClickCreateTodaysButtonHandler,
-    onClickTemplateForChildrenButtonHandler,
-    onClickTemplateForDescendantsButtonHandler,
+    onClickTemplateButtonHandler,
   } = props;
 
   const { t } = useTranslation('commons');
@@ -48,7 +48,7 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
       <li>
         <button
           className="dropdown-item"
-          onClick={onClickTemplateForChildrenButtonHandler}
+          onClick={() => onClickTemplateButtonHandler('_template')}
           type="button"
         >
           {t('create_page_dropdown.template.children')}
@@ -57,10 +57,10 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
       <li>
         <button
           className="dropdown-item"
-          onClick={onClickTemplateForDescendantsButtonHandler}
+          onClick={() => onClickTemplateButtonHandler('__template')}
           type="button"
         >
-          {t('create_page_dropdown.template.decendants')}
+          {t('create_page_dropdown.template.descendants')}
         </button>
       </li>
     </ul>

+ 21 - 147
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -1,175 +1,50 @@
-import React, { useCallback, useState } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { format } from 'date-fns';
-import { useRouter } from 'next/router';
 
-import { createPage, exist } from '~/client/services/page-operation';
+import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
 import { toastError } from '~/client/util/toastr';
+import { LabelType } from '~/interfaces/template';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxCurrentPage } from '~/stores/page';
-import loggerFactory from '~/utils/logger';
 
 import { CreateButton } from './CreateButton';
 import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
-
-const logger = loggerFactory('growi:cli:PageCreateButton');
+import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
 
 export const PageCreateButton = React.memo((): JSX.Element => {
-  const router = useRouter();
   const { data: currentPage, isLoading } = useSWRxCurrentPage();
   const { data: currentUser } = useCurrentUser();
 
   const [isHovered, setIsHovered] = useState(false);
-  const [isCreating, setIsCreating] = useState(false);
 
   const now = format(new Date(), 'yyyy/MM/dd');
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
   const todaysPath = `${userHomepagePath}/memo/${now}`;
 
-  const onMouseEnterHandler = () => {
-    setIsHovered(true);
-  };
-
-  const onMouseLeaveHandler = () => {
-    setIsHovered(false);
-  };
-
-  const onClickCreateNewPageButtonHandler = useCallback(async() => {
-    if (isLoading) return;
-
-    try {
-      setIsCreating(true);
-
-      const parentPath = currentPage == null
-        ? '/'
-        : currentPage.path;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-        // grant: currentPage?.grant || 1,
-        // grantUserGroupId: currentPage?.grantedGroup?._id,
-        shouldGeneratePath: true,
-      };
-
-      const response = await createPage(parentPath, '', params);
-
-      router.push(`${response.page.id}#edit`);
-    }
-    catch (err) {
-      logger.warn(err);
-      toastError(err);
-    }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentPage, isLoading, router]);
-
-  const onClickCreateTodaysButtonHandler = useCallback(async() => {
-    if (currentUser == null) {
-      return;
-    }
-
-    try {
-      setIsCreating(true);
-
-      // TODO: get grant, grantUserGroupId data from parent page
-      // https://redmine.weseek.co.jp/issues/133892
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-      };
-
-      const res = await exist(JSON.stringify([todaysPath]));
-      if (!res.pages[todaysPath]) {
-        await createPage(todaysPath, '', params);
-      }
-
-      router.push(`${todaysPath}#edit`);
-    }
-    catch (err) {
-      logger.warn(err);
-      toastError(err);
-    }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentUser, router, todaysPath]);
-
-  const onClickTemplateForChildrenButtonHandler = useCallback(async() => {
-    if (isLoading) return;
+  const { onClickHandler: onClickNewButton, isPageCreating: isNewPageCreating } = useOnNewButtonClicked(isLoading, currentPage);
+  const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath, currentUser);
+  const { onClickHandler: onClickTemplateButton, isPageCreating: isTemplatePageCreating } = useOnTemplateButtonClicked(currentPage?.path);
 
+  const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
     try {
-      setIsCreating(true);
-
-      const path = currentPage == null || currentPage.path === '/'
-        ? '/_template'
-        : `${currentPage.path}/_template`;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-        // grant: currentPage?.grant || 1,
-        // grantUserGroupId: currentPage?.grantedGroup?._id,
-      };
-
-      const res = await exist(JSON.stringify([path]));
-      if (!res.pages[path]) {
-        await createPage(path, '', params);
-      }
-
-      router.push(`${path}#edit`);
+      await onClickTemplateButton(label);
     }
     catch (err) {
-      logger.warn(err);
       toastError(err);
     }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentPage, isLoading, router]);
-
-  const onClickTemplateForDescendantsButtonHandler = useCallback(async() => {
-    if (isLoading) return;
-
-    try {
-      setIsCreating(true);
+  }, [onClickTemplateButton]);
 
-      const path = currentPage == null || currentPage.path === '/'
-        ? '/__template'
-        : `${currentPage.path}/__template`;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-        // grant: currentPage?.grant || 1,
-        // grantUserGroupId: currentPage?.grantedGroup?._id,
-      };
-
-      const res = await exist(JSON.stringify([path]));
-      if (!res.pages[path]) {
-        await createPage(path, '', params);
-      }
+  const onMouseEnterHandler = () => {
+    setIsHovered(true);
+  };
 
-      router.push(`${path}#edit`);
-    }
-    catch (err) {
-      logger.warn(err);
-      toastError(err);
-    }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentPage, isLoading, router]);
+  const onMouseLeaveHandler = () => {
+    setIsHovered(false);
+  };
 
-  // TODO: update button design
-  // https://redmine.weseek.co.jp/issues/132683
   return (
     <div
       className="d-flex flex-row"
@@ -179,8 +54,8 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       <div className="btn-group flex-grow-1">
         <CreateButton
           className="z-2"
-          onClick={onClickCreateNewPageButtonHandler}
-          disabled={isCreating}
+          onClick={onClickNewButton}
+          disabled={isNewPageCreating || isTodaysPageCreating || isTemplatePageCreating}
         />
       </div>
       { isHovered && (
@@ -192,10 +67,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
           />
           <DropendMenu
             todaysPath={todaysPath}
-            onClickCreateNewPageButtonHandler={onClickCreateNewPageButtonHandler}
-            onClickCreateTodaysButtonHandler={onClickCreateTodaysButtonHandler}
-            onClickTemplateForChildrenButtonHandler={onClickTemplateForChildrenButtonHandler}
-            onClickTemplateForDescendantsButtonHandler={onClickTemplateForDescendantsButtonHandler}
+            onClickCreateNewPageButtonHandler={onClickNewButton}
+            onClickCreateTodaysButtonHandler={onClickTodaysButton}
+            onClickTemplateButtonHandler={onClickTemplateButtonHandler}
           />
         </div>
       )}

+ 95 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx

@@ -0,0 +1,95 @@
+import { useCallback, useState } from 'react';
+
+import type { Nullable, IPagePopulatedToShowRevision, IUserHasId } from '@growi/core';
+import { useRouter } from 'next/router';
+
+import { createPage, exist } from '~/client/services/page-operation';
+import { toastError } from '~/client/util/toastr';
+
+export const useOnNewButtonClicked = (
+    isLoading: boolean,
+    currentPage?: IPagePopulatedToShowRevision | null,
+): {
+  onClickHandler: () => Promise<void>,
+  isPageCreating: boolean
+} => {
+  const router = useRouter();
+  const [isPageCreating, setIsPageCreating] = useState(false);
+
+  const onClickHandler = useCallback(async() => {
+    if (isLoading) return;
+
+    try {
+      setIsPageCreating(true);
+
+      const parentPath = currentPage == null
+        ? '/'
+        : currentPage.path;
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+        // grant: currentPage?.grant || 1,
+        // grantUserGroupId: currentPage?.grantedGroup?._id,
+        shouldGeneratePath: true,
+      };
+
+      const response = await createPage(parentPath, '', params);
+
+      router.push(`${response.page.id}#edit`);
+    }
+    catch (err) {
+      toastError(err);
+    }
+    finally {
+      setIsPageCreating(false);
+    }
+  }, [currentPage, isLoading, router]);
+
+  return { onClickHandler, isPageCreating };
+};
+
+export const useOnTodaysButtonClicked = (
+    todaysPath: string,
+    currentUser?: Nullable<IUserHasId> | undefined,
+): {
+  onClickHandler: () => Promise<void>,
+  isPageCreating: boolean
+} => {
+  const router = useRouter();
+  const [isPageCreating, setIsPageCreating] = useState(false);
+
+  const onClickHandler = useCallback(async() => {
+    if (currentUser == null) {
+      return;
+    }
+
+    try {
+      setIsPageCreating(true);
+
+      // TODO: get grant, grantUserGroupId data from parent page
+      // https://redmine.weseek.co.jp/issues/133892
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+      };
+
+      const res = await exist(JSON.stringify([todaysPath]));
+      if (!res.pages[todaysPath]) {
+        await createPage(todaysPath, '', params);
+      }
+
+      router.push(`${todaysPath}#edit`);
+    }
+    catch (err) {
+      toastError(err);
+    }
+    finally {
+      setIsPageCreating(false);
+    }
+  }, [currentUser, router, todaysPath]);
+
+  return { onClickHandler, isPageCreating };
+};

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

@@ -201,7 +201,7 @@ export const Sidebar = (): JSX.Element => {
         </DrawerToggler>
       ) }
       { sidebarMode != null && !isDockMode() && <AppTitleOnSubnavigation /> }
-      <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end vh-100`} data-testid="grw-sidebar">
+      <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} data-testid="grw-sidebar">
         <ResizableContainer>
           { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }
           <SidebarHead />

+ 1 - 1
apps/app/src/components/Sidebar/Tag.tsx

@@ -44,7 +44,7 @@ const Tag: FC = () => {
 
   // todo: adjust design by XD
   return (
-    <div className="grw-container-convertible container-lg px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
+    <div className="container-lg px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
       <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">{t('Tags')}</h3>
         <SidebarHeaderReloadButton onClick={() => onReload()} />

+ 2 - 0
apps/app/src/interfaces/template.ts

@@ -0,0 +1,2 @@
+export type TargetType = 'children' | 'descendants';
+export type LabelType = '_template' | '__template';

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

@@ -5,7 +5,7 @@ import EventEmitter from 'events';
 
 import { isIPageInfoForEntity } from '@growi/core';
 import type {
-  IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision, IUserHasId,
+  IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision,
 } from '@growi/core';
 import {
   isClient, pagePathUtils, pathUtils,
@@ -20,7 +20,7 @@ import Head from 'next/head';
 import { useRouter } from 'next/router';
 import superjson from 'superjson';
 
-import { useEditorModeClassName, useLayoutFluidClassNameByPage } from '~/client/services/layout';
+import { useEditorModeClassName } from '~/client/services/layout';
 import { PageView } from '~/components/Page/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript'; import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { EditorConfig } from '~/interfaces/editor-settings';
@@ -249,8 +249,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useSetupGlobalSocket();
   useSetupGlobalSocketForPage(pageId);
 
-  const growiLayoutFluidClass = useLayoutFluidClassNameByPage(pageWithMeta?.data);
-
   // Store initial data (When revisionBody is not SSR)
   useEffect(() => {
     if (!props.skipSSR) {
@@ -323,7 +321,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
       <Head>
         <title>{title}</title>
       </Head>
-      <div className={`dynamic-layout-root ${growiLayoutFluidClass} justify-content-between`}>
+      <div className="dynamic-layout-root justify-content-between">
         <nav className="sticky-top">
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
         </nav>

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

@@ -125,7 +125,7 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
 
         <div id="main" className="main">
-          <div id="content-main" className="content-main container-lg grw-container-convertible">
+          <div id="content-main" className="content-main container-lg">
             {targetPage.component}
           </div>
         </div>

+ 2 - 5
apps/app/src/pages/share/[[...path]].page.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect } from 'react';
 
-import { type IUserHasId, type IPagePopulatedToShowRevision, getIdForRef } from '@growi/core';
+import { type IPagePopulatedToShowRevision, getIdForRef } from '@growi/core';
 import type {
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -8,7 +8,6 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import Head from 'next/head';
 import superjson from 'superjson';
 
-import { useLayoutFluidClassNameByPage } from '~/client/services/layout';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
@@ -108,8 +107,6 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   }, [mutateCurrentPage, props.isNotFound, props.shareLink?.relatedPage._id, props.skipSSR]);
 
 
-  const growiLayoutFluidClass = useLayoutFluidClassNameByPage(props.shareLinkRelatedPage);
-
   const pagePath = props.shareLinkRelatedPage?.path ?? '';
 
   const title = generateCustomTitleForPage(props, pagePath);
@@ -120,7 +117,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
         <title>{title}</title>
       </Head>
 
-      <div className={`dynamic-layout-root ${growiLayoutFluidClass} justify-content-between`}>
+      <div className="dynamic-layout-root justify-content-between">
         <nav className="sticky-top">
           <GrowiContextualSubNavigationForSharedPage page={currentPage ?? props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />
         </nav>

+ 1 - 1
apps/app/src/pages/tags.page.tsx

@@ -73,7 +73,7 @@ const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
         <title>{title}</title>
       </Head>
       <div className="dynamic-layout-root">
-        <div className="grw-container-convertible container-lg mb-5 pb-5" data-testid="tags-page">
+        <div className="container-lg mb-5 pb-5" data-testid="tags-page">
           <h2 className="my-3">{`${t('Tags')}(${totalCount})`}</h2>
           <div className="px-3 mb-5 text-center">
             <TagCloudBox tags={tagData} minSize={20} />

+ 1 - 1
apps/app/src/pages/trash.page.tsx

@@ -68,7 +68,7 @@ const TrashPage: NextPageWithLayout<CommonProps> = (props: Props) => {
           TODO: implement navigation for /trash
         </nav>
 
-        <div className="content-main container-lg grw-container-convertible mb-5 pb-5">
+        <div className="content-main container-lg mb-5 pb-5">
           <PagePathNavSticky pagePath="/trash" />
           <TrashPageList />
         </div>

+ 2 - 5
apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts

@@ -26,20 +26,17 @@ export const certifySharedPageAttachmentMiddleware = async(req: RequestToAllowSh
 
   const validReferer = validateReferer(referer);
   if (!validReferer) {
-    logger.info('invalid referer.');
     return next();
   }
 
-  logger.info('referer is valid.');
-
   const shareLink = await retrieveValidShareLinkByReferer(validReferer);
   if (shareLink == null) {
-    logger.info(`No valid ShareLink document found by the referer (${validReferer.referer}})`);
+    logger.warn(`No valid ShareLink document found by the referer (${validReferer.referer}})`);
     return next();
   }
 
   if (!(await validateAttachment(fileId, shareLink))) {
-    logger.info(`No valid ShareLink document found by the fileId (${fileId}) and referer (${validReferer.referer}})`);
+    logger.warn(`No valid ShareLink document found by the fileId (${fileId}) and referer (${validReferer.referer}})`);
     return next();
   }
 

+ 2 - 2
apps/app/src/server/middlewares/certify-shared-page-attachment/retrieve-valid-share-link.ts

@@ -15,9 +15,9 @@ export const retrieveValidShareLinkByReferer = async(referer: ValidReferer): Pro
     return null;
   }
 
-  const shareLinkId = referer;
+  const { shareLinkId } = referer;
   const shareLink = await ShareLink.findOne({
-    id: shareLinkId,
+    _id: shareLinkId,
   });
   if (shareLink == null || shareLink.isExpired()) {
     logger.info(`ShareLink ('${shareLinkId}') is not found or has already expired.`);

+ 1 - 1
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.ts

@@ -2,7 +2,7 @@ import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
 
-const logger = loggerFactory('growi:middlewares:certify-shared-file:validate-referer:retrieve-site-url');
+const logger = loggerFactory('growi:middlewares:certify-shared-page-attachment:validate-referer:retrieve-site-url');
 
 
 export const retrieveSiteUrl = (): URL | null => {

+ 5 - 2
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.ts

@@ -7,7 +7,7 @@ import { ValidReferer } from '../interfaces';
 import { retrieveSiteUrl } from './retrieve-site-url';
 
 
-const logger = loggerFactory('growi:middlewares:certify-shared-file:validate-referer');
+const logger = loggerFactory('growi:middlewares:certify-shared-page-attachment:validate-referer');
 
 
 export const validateReferer = (referer: string | undefined): ValidReferer | false => {
@@ -51,7 +51,10 @@ export const validateReferer = (referer: string | undefined): ValidReferer | fal
   // validate pathname
   // https://regex101.com/r/M5Bp6E/1
   const match = refererUrl.pathname.match(/^\/share\/(?<shareLinkId>[a-f0-9]{24})$/i);
-  if (match == null || match.groups?.shareLinkId == null) {
+  if (match == null) {
+    return false;
+  }
+  if (match.groups?.shareLinkId == null) {
     logger.warn(`The pathname ('${refererUrl.pathname}') is invalid.`, match);
     return false;
   }

+ 0 - 5
apps/app/src/styles/_layout.scss

@@ -6,11 +6,6 @@
   @extend .flex-expand-vert;
 }
 
-.dynamic-layout-root.growi-layout-fluid .grw-container-convertible {
-  width: 100%;
-  max-width: none;
-}
-
 .grw-bg-image-wrapper {
   position: fixed;
   width: 100%;

+ 1 - 0
apps/app/src/styles/_mixins.scss

@@ -2,6 +2,7 @@
 @use './variables' as var;
 
 @import './mixins/editing';
+@import './mixins/fluid-layout';
 @import './mixins/share-link';
 
 @mixin variable-font-size($basesize) {

+ 4 - 0
apps/app/src/styles/mixins/_fluid-layout.scss

@@ -0,0 +1,4 @@
+@mixin fluid-layout() {
+  width: 100%;
+  max-width: none;
+}

+ 2 - 1
apps/app/src/styles/organisms/_wiki.scss

@@ -294,7 +294,7 @@
 
 // == Colors
 .wiki {
-  a {
+  a:not(.alert-link) {
     @extend .link-underline-opacity-25;
     @extend .link-underline-opacity-100-hover;
 
@@ -309,5 +309,6 @@
         var(--bs-link-opacity, 1)
       );
     }
+
   }
 }

+ 1 - 1
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts

@@ -303,7 +303,7 @@ context('Access to Template Editing Mode', () => {
     cy.getByTestid('open-page-template-modal-btn').filter(':visible').click({force: true});
     cy.getByTestid('page-template-modal').should('be.visible');
 
-    cy.getByTestid('template-button-decendants').click(({force: true}))
+    cy.getByTestid('template-button-descendants').click(({force: true}))
     cy.waitUntilSkeletonDisappear();
 
     cy.getByTestid('navbar-editor').should('be.visible').then(()=>{

+ 3 - 1
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts

@@ -269,7 +269,9 @@ describe('Access to sidebar', () => {
         });
 
         it('Succesfully click all tags button', () => {
-          cy.get('.grw-container-convertible > div > .btn-primary').click({force: true});
+          cy.getByTestid('grw-sidebar-content-tags').within(() => {
+            cy.get('.btn-primary').click({force: true});
+          });
           cy.collapseSidebar(true);
           cy.getByTestid('grw-tags-list').should('be.visible');
 

+ 1 - 2
packages/core/scss/_flex-expand.scss

@@ -2,14 +2,12 @@
   display: flex;
   flex-direction: row;
   flex-grow: 1;
-  height: 100%;
 }
 
 .flex-expand-vert {
   display: flex;
   flex: 1;
   flex-direction: column;
-  height: 100%;
 }
 
 .flex-expand-vh-100 {
@@ -17,6 +15,7 @@
 
   .flex-expand-horiz,
   .flex-expand-vert {
+    height: 100%;
     overflow-y: auto;
   }
 }

+ 14 - 0
packages/core/scss/bootstrap/_variables-dark.scss

@@ -0,0 +1,14 @@
+$success-text-emphasis-dark:        mix(#fff, $success, 20%) !default;
+$info-text-emphasis-dark:           mix(#fff, $info, 20%) !default;
+$warning-text-emphasis-dark:        mix(#fff, $warning, 20%) !default;
+$danger-text-emphasis-dark:         mix(#fff, $danger, 20%) !default;
+
+$success-bg-subtle-dark:            mix($gray-900, $success, 85%) !default;
+$info-bg-subtle-dark:               mix($gray-900, $info, 85%) !default;
+$warning-bg-subtle-dark:            mix($gray-900, $warning, 85%) !default;
+$danger-bg-subtle-dark:             mix($gray-900, $danger, 85%) !default;
+
+$success-border-subtle-dark:        mix($gray-900, $success, 50%) !default;
+$info-border-subtle-dark:           mix($gray-900, $info, 50%) !default;
+$warning-border-subtle-dark:        mix($gray-900, $warning, 50%) !default;
+$danger-border-subtle-dark:         mix($gray-900, $danger, 50%) !default;

+ 22 - 0
packages/core/scss/bootstrap/_variables.scss

@@ -5,6 +5,28 @@
 // Variables should follow the `$component-state-property-size` formula for
 // consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs.
 
+// Color system
+$success:       #4a9017 !default;
+$info:          #4689aa !default;
+$warning:       #c99818 !default;
+$danger:        #de4d4d !default;
+
+$success-text-emphasis:       mix($gray-900, $success, 30%) !default;
+$info-text-emphasis:          mix($gray-900, $info, 30%) !default;
+$warning-text-emphasis:       mix($gray-900, $warning, 30%) !default;
+$danger-text-emphasis:        mix($gray-900, $danger, 30%) !default;
+
+$success-bg-subtle:           mix(#fff, $success, 90%) !default;
+$info-bg-subtle:              mix(#fff, $info, 90%) !default;
+$warning-bg-subtle:           mix(#fff, $warning, 90%) !default;
+$danger-bg-subtle:            mix(#fff, $danger, 90%) !default;
+
+$success-border-subtle:       mix(#fff, $success, 70%) !default;
+$info-border-subtle:          mix(#fff, $info, 70%) !default;
+$warning-border-subtle:       mix(#fff, $warning, 70%) !default;
+$danger-border-subtle:        mix(#fff, $danger, 70%) !default;
+
+
 // Options
 //
 // Quickly modify global styling by enabling or disabling optional features.

+ 2 - 1
packages/core/scss/bootstrap/apply.scss

@@ -42,4 +42,5 @@
 @import 'bootstrap/scss/utilities/api';
 
 // override
-@import './override';
+@import './override/badge';
+@import './override/buttons';

+ 1 - 0
packages/core/scss/bootstrap/init.scss

@@ -2,6 +2,7 @@
 
 @import './theming/variables';
 @import './variables';
+@import './variables-dark';
 @import 'bootstrap/scss/variables';
 @import 'bootstrap/scss/variables-dark';
 

+ 59 - 0
packages/core/scss/bootstrap/mixins/_button-outline-variant.scss

@@ -0,0 +1,59 @@
+@mixin button-outline-variant-light(
+  $color,
+  $background: mix(#fff, $color, 90%),
+  $border: $color,
+  $hover-background: mix(#fff, $color, 85%),
+  $hover-border: $border,
+  $hover-color: $color,
+  $active-background: mix(#fff, $color, 70%),
+  $active-border: $border,
+  $active-color: $color,
+  $disabled-background: $background,
+  $disabled-border: $border,
+  $disabled-color: $color
+) {
+
+  --#{$prefix}btn-color: #{$color};
+  --#{$prefix}btn-bg: #{$background};
+  --#{$prefix}btn-border-color: #{$border};
+  --#{$prefix}btn-hover-color: #{$hover-color};
+  --#{$prefix}btn-hover-bg: #{$hover-background};
+  --#{$prefix}btn-hover-border-color: #{$hover-border};
+  --#{$prefix}btn-active-color: #{$active-color};
+  --#{$prefix}btn-active-bg: #{$active-background};
+  --#{$prefix}btn-active-border-color: #{$active-border};
+  --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow};
+  --#{$prefix}btn-disabled-color: #{$disabled-color};
+  --#{$prefix}btn-disabled-bg: #{$disabled-background};
+  --#{$prefix}btn-disabled-border-color: #{$disabled-border};
+}
+
+@mixin button-outline-variant-dark(
+  $color,
+  $background: mix($gray-900, $color, 85%),
+  $border: mix($gray-900, $color, 50%),
+  $hover-background: mix($gray-900, $color, 80%),
+  $hover-border: $border,
+  $hover-color: $color,
+  $active-background: mix($gray-900, $color, 65%),
+  $active-border: $border,
+  $active-color: $color,
+  $disabled-background: $background,
+  $disabled-border: $border,
+  $disabled-color: $color
+) {
+
+  --#{$prefix}btn-color: #{$color};
+  --#{$prefix}btn-bg: #{$background};
+  --#{$prefix}btn-border-color: #{$border};
+  --#{$prefix}btn-hover-color: #{$hover-color};
+  --#{$prefix}btn-hover-bg: #{$hover-background};
+  --#{$prefix}btn-hover-border-color: #{$hover-border};
+  --#{$prefix}btn-active-color: #{$active-color};
+  --#{$prefix}btn-active-bg: #{$active-background};
+  --#{$prefix}btn-active-border-color: #{$active-border};
+  --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow};
+  --#{$prefix}btn-disabled-color: #{$disabled-color};
+  --#{$prefix}btn-disabled-bg: #{$disabled-background};
+  --#{$prefix}btn-disabled-border-color: #{$disabled-border};
+}

+ 3 - 0
packages/core/scss/bootstrap/override/_badge.scss

@@ -0,0 +1,3 @@
+.badge {
+  @extend .rounded-pill;
+}

+ 21 - 0
packages/core/scss/bootstrap/override/_buttons.scss

@@ -0,0 +1,21 @@
+@import '../mixins/button-outline-variant';
+
+:root[data-bs-theme='light'] {
+  @each $color, $value in $theme-colors {
+    @if $color != 'primary' and $color != 'secondary' {
+      .btn-outline-#{$color} {
+        @include button-outline-variant-light($value);
+      }
+    }
+  }
+}
+
+:root[data-bs-theme='dark'] {
+  @each $color, $value in $theme-colors {
+    @if $color != 'primary' and $color != 'secondary' {
+      .btn-outline-#{$color} {
+        @include button-outline-variant-dark($value);
+      }
+    }
+  }
+}

+ 7 - 5
packages/core/scss/bootstrap/theming/_buttons.scss → packages/core/scss/bootstrap/theming/_buttons-dark.scss

@@ -1,15 +1,17 @@
+@import '../mixins/button-outline-variant';
+
 .btn-primary {
   @include button-variant($primary, $primary);
 }
 
-.btn-outline-primary {
-  @include button-outline-variant($primary);
-}
-
 .btn-secondary {
   @include button-variant($secondary, $secondary);
 }
 
+.btn-outline-primary {
+  @include button-outline-variant-dark($primary);
+}
+
 .btn-outline-secondary {
-  @include button-outline-variant($secondary);
+  @include button-outline-variant-dark($secondary);
 }

+ 17 - 0
packages/core/scss/bootstrap/theming/_buttons-light.scss

@@ -0,0 +1,17 @@
+@import '../mixins/button-outline-variant';
+
+.btn-primary {
+  @include button-variant($primary, $primary);
+}
+
+.btn-secondary {
+  @include button-variant($secondary, $secondary);
+}
+
+.btn-outline-primary {
+  @include button-outline-variant-light($primary);
+}
+
+.btn-outline-secondary {
+  @include button-outline-variant-light($secondary);
+}

+ 3 - 1
packages/core/scss/bootstrap/theming/apply.scss → packages/core/scss/bootstrap/theming/apply-dark.scss

@@ -1,5 +1,7 @@
+@import './root';
+@import './root-dark';
 @import './tables';
-@import './buttons';
+@import './buttons-dark';
 @import './pagination';
 @import './progress';
 @import './list-group';

+ 7 - 0
packages/core/scss/bootstrap/theming/apply-light.scss

@@ -0,0 +1,7 @@
+@import './root';
+@import './root-light';
+@import './tables';
+@import './buttons-light';
+@import './pagination';
+@import './progress';
+@import './list-group';

+ 26 - 10
packages/editor/src/components/CodeMirrorEditor/Toolbar/TextFormatTools.tsx

@@ -2,6 +2,8 @@ import { useCallback, useState } from 'react';
 
 import { Collapse } from 'reactstrap';
 
+import type { GlobalCodeMirrorEditorKey } from '../../../consts';
+import { useCodeMirrorEditorIsolated } from '../../../stores';
 
 import styles from './TextFormatTools.module.scss';
 
@@ -30,44 +32,58 @@ const TextFormatToolsToggler = (props: TogglarProps): JSX.Element => {
   );
 };
 
-export const TextFormatTools = (): JSX.Element => {
+type TextFormatToolsType = {
+  editorKey: string | GlobalCodeMirrorEditorKey,
+}
+
+export const TextFormatTools = (props: TextFormatToolsType): JSX.Element => {
+  const { editorKey } = props;
   const [isOpen, setOpen] = useState(false);
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
 
   const toggle = useCallback(() => {
     setOpen(bool => !bool);
   }, []);
 
+  const onClickInsertMarkdownElements = (prefix: string, suffix: string) => {
+    codeMirrorEditor?.insertMarkdownElements(prefix, suffix);
+  };
+
+  const onClickInsertPrefix = (prefix: string, noSpaceIfPrefixExists?: boolean) => {
+    codeMirrorEditor?.insertPrefix(prefix, noSpaceIfPrefixExists);
+  };
+
   return (
     <div className="d-flex">
       <TextFormatToolsToggler isOpen={isOpen} onClick={toggle} />
 
       <Collapse isOpen={isOpen} horizontal>
         <div className="d-flex px-1 gap-1" style={{ width: '220px' }}>
-          <button type="button" className="btn btn-toolbar-button">
+          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertMarkdownElements('**', '**')}>
             <span className="material-symbols-outlined fs-5">format_bold</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button">
+          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertMarkdownElements('*', '*')}>
             <span className="material-symbols-outlined fs-5">format_italic</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button">
+          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertMarkdownElements('~', '~')}>
             <span className="material-symbols-outlined fs-5">format_strikethrough</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button">
+          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertPrefix('#', true)}>
             <span className="material-symbols-outlined fs-5">block</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button">
+          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertMarkdownElements('`', '`')}>
             <span className="material-symbols-outlined fs-5">code</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button">
+          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertPrefix('-')}>
             <span className="material-symbols-outlined fs-5">format_list_bulleted</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button">
+          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertPrefix('1.')}>
             <span className="material-symbols-outlined fs-5">format_list_numbered</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button">
+          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertPrefix('>')}>
             <span className="material-symbols-outlined fs-5">block</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button">
+          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertPrefix('- [ ]')}>
             <span className="material-symbols-outlined fs-5">checklist</span>
           </button>
         </div>

+ 3 - 5
packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx

@@ -1,6 +1,6 @@
 import { memo } from 'react';
 
-import { AcceptedUploadFileType } from '../../../consts';
+import type { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../../consts';
 
 import { AttachmentsDropup } from './AttachmentsDropup';
 import { DiagramButton } from './DiagramButton';
@@ -9,11 +9,10 @@ import { TableButton } from './TableButton';
 import { TemplateButton } from './TemplateButton';
 import { TextFormatTools } from './TextFormatTools';
 
-
 import styles from './Toolbar.module.scss';
 
 type Props = {
-  editorKey: string,
+  editorKey: string | GlobalCodeMirrorEditorKey,
   onFileOpen: () => void,
   acceptedFileType: AcceptedUploadFileType
 }
@@ -21,11 +20,10 @@ type Props = {
 export const Toolbar = memo((props: Props): JSX.Element => {
 
   const { editorKey, onFileOpen, acceptedFileType } = props;
-
   return (
     <div className={`d-flex gap-2 p-2 codemirror-editor-toolbar ${styles['codemirror-editor-toolbar']}`}>
       <AttachmentsDropup onFileOpen={onFileOpen} acceptedFileType={acceptedFileType} />
-      <TextFormatTools />
+      <TextFormatTools editorKey={editorKey} />
       <EmojiButton
         editorKey={editorKey}
       />

+ 8 - 0
packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts

@@ -16,6 +16,8 @@ import { useAppendExtensions, type AppendExtensions } from './utils/append-exten
 import { useFocus, type Focus } from './utils/focus';
 import { useGetDoc, type GetDoc } from './utils/get-doc';
 import { useInitDoc, type InitDoc } from './utils/init-doc';
+import { useInsertMarkdownElements, type InsertMarkdowElements } from './utils/insert-markdown-elements';
+import { useInsertPrefix, type InsertPrefix } from './utils/insert-prefix';
 import { useInsertText, type InsertText } from './utils/insert-text';
 import { useReplaceText, type ReplaceText } from './utils/replace-text';
 import { useSetCaretLine, type SetCaretLine } from './utils/set-caret-line';
@@ -37,6 +39,8 @@ type UseCodeMirrorEditorUtils = {
   setCaretLine: SetCaretLine,
   insertText: InsertText,
   replaceText: ReplaceText,
+  insertMarkdownElements: InsertMarkdowElements,
+  insertPrefix: InsertPrefix,
 }
 export type UseCodeMirrorEditor = {
   state: EditorState | undefined;
@@ -89,6 +93,8 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
   const setCaretLine = useSetCaretLine(view);
   const insertText = useInsertText(view);
   const replaceText = useReplaceText(view);
+  const insertMarkdownElements = useInsertMarkdownElements(view);
+  const insertPrefix = useInsertPrefix(view);
 
   return {
     state,
@@ -100,5 +106,7 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
     setCaretLine,
     insertText,
     replaceText,
+    insertMarkdownElements,
+    insertPrefix,
   };
 };

+ 27 - 0
packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/insert-markdown-elements.ts

@@ -0,0 +1,27 @@
+import { useCallback } from 'react';
+
+import { EditorView } from '@codemirror/view';
+
+export type InsertMarkdowElements = (
+  prefix: string,
+  suffix: string,
+) => void;
+
+export const useInsertMarkdownElements = (view?: EditorView): InsertMarkdowElements => {
+
+  return useCallback((prefix, suffix) => {
+    const selection = view?.state.sliceDoc(
+      view?.state.selection.main.from,
+      view?.state.selection.main.to,
+    );
+    const cursorPos = view?.state.selection.main.head;
+    const insertText = view?.state.replaceSelection(prefix + selection + suffix);
+
+    if (insertText == null || cursorPos == null) {
+      return;
+    }
+    view?.dispatch(insertText);
+    view?.dispatch({ selection: { anchor: cursorPos + prefix.length } });
+    view?.focus();
+  }, [view]);
+};

+ 35 - 0
packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/insert-prefix.ts

@@ -0,0 +1,35 @@
+import { useCallback } from 'react';
+
+import { EditorView } from '@codemirror/view';
+
+export type InsertPrefix = (prefix: string, noSpaceIfPrefixExists?: boolean) => void;
+
+export const useInsertPrefix = (view?: EditorView): InsertPrefix => {
+  return useCallback((prefix: string, noSpaceIfPrefixExists = false) => {
+    if (view == null) {
+      return;
+    }
+
+    // get the line numbers of the selected range
+    const { from, to } = view.state.selection.main;
+    const startLine = view.state.doc.lineAt(from);
+    const endLine = view.state.doc.lineAt(to);
+
+    // Insert prefix for each line
+    const lines = [];
+    let insertTextLength = 0;
+    for (let i = startLine.number; i <= endLine.number; i++) {
+      const line = view.state.doc.line(i);
+      const insertText = noSpaceIfPrefixExists && line.text.startsWith(prefix)
+        ? prefix
+        : `${prefix} `;
+      insertTextLength += insertText.length;
+      lines.push({ from: line.from, insert: insertText });
+    }
+    view.dispatch({ changes: lines });
+
+    // move the cursor to the end of the selected line
+    view.dispatch({ selection: { anchor: endLine.to + insertTextLength } });
+    view.focus();
+  }, [view]);
+};

+ 0 - 1
packages/editor/vite.config.ts

@@ -8,7 +8,6 @@ import dts from 'vite-plugin-dts';
 
 
 const excludeFiles = [
-  '**/@types/*',
   '**/components/playground/*',
   '**/main.tsx',
   '**/vite-env.d.ts',

+ 2 - 6
packages/preset-themes/src/styles/default.scss

@@ -27,9 +27,7 @@
 
   @import '@growi/core/scss/bootstrap/init-stage-2';
 
-  @import '@growi/core/scss/bootstrap/theming/root';
-  @import '@growi/core/scss/bootstrap/theming/root-light';
-  @import '@growi/core/scss/bootstrap/theming/apply';
+  @import '@growi/core/scss/bootstrap/theming/apply-light';
 
   --grw-wiki-link-color-rgb: var(--grw-highlight-800-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-highlight-900-rgb);
@@ -65,9 +63,7 @@
 
   @import '@growi/core/scss/bootstrap/init-stage-2';
 
-  @import '@growi/core/scss/bootstrap/theming/root';
-  @import '@growi/core/scss/bootstrap/theming/root-dark';
-  @import '@growi/core/scss/bootstrap/theming/apply';
+  @import '@growi/core/scss/bootstrap/theming/apply-dark';
 
   --grw-wiki-link-color-rgb: var(--grw-highlight-500-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-highlight-300-rgb);

+ 2 - 6
packages/preset-themes/src/styles/mono-blue.scss

@@ -27,9 +27,7 @@
 
   @import '@growi/core/scss/bootstrap/init-stage-2';
 
-  @import '@growi/core/scss/bootstrap/theming/root';
-  @import '@growi/core/scss/bootstrap/theming/root-light';
-  @import '@growi/core/scss/bootstrap/theming/apply';
+  @import '@growi/core/scss/bootstrap/theming/apply-light';
 
   --grw-wiki-link-color-rgb: var(--grw-primary-500-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-700-rgb);
@@ -64,9 +62,7 @@
 
   @import '@growi/core/scss/bootstrap/init-stage-2';
 
-  @import '@growi/core/scss/bootstrap/theming/root';
-  @import '@growi/core/scss/bootstrap/theming/root-dark';
-  @import '@growi/core/scss/bootstrap/theming/apply';
+  @import '@growi/core/scss/bootstrap/theming/apply-dark';
 
   --grw-wiki-link-color-rgb: var(--grw-primary-500-rgb);
   --grw-wiki-link-hover-color-rgb: var(--grw-primary-300-rgb);