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

Merge branch 'dev/7.0.x' into 126524-139354-support-keybind

reiji-h 2 лет назад
Родитель
Сommit
2011d2a60e
51 измененных файлов с 1101 добавлено и 378 удалено
  1. 1 3
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  2. 0 15
      apps/app/src/components/Me/ColorModeSettings.module.scss
  3. 34 26
      apps/app/src/components/Me/ColorModeSettings.tsx
  4. 37 11
      apps/app/src/components/PageComment/CommentEditor.tsx
  5. 66 55
      apps/app/src/components/PageEditor/OptionsSelector.tsx
  6. 4 0
      apps/app/src/components/PageEditor/PageEditor.tsx
  7. 5 4
      apps/app/src/components/PageSelectModal/TreeItemForModal.tsx
  8. 2 2
      apps/app/src/components/PageSideContents/PageAccessoriesControl.tsx
  9. 1 1
      apps/app/src/components/PageTags/RenderTagLabels.tsx
  10. 28 40
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  11. 8 9
      apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx
  12. 15 7
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  13. 0 2
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  14. 19 3
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  15. 30 21
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss
  16. 73 37
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  17. 1 3
      apps/app/src/components/Sidebar/Tag.tsx
  18. 2 2
      apps/app/src/components/TagCloudBox.tsx
  19. 27 0
      apps/app/src/components/TagList.module.scss
  20. 11 6
      apps/app/src/components/TagList.tsx
  21. 43 33
      apps/app/src/components/TreeItem/SimpleItem.tsx
  22. 3 1
      apps/app/src/components/TreeItem/interfaces/index.ts
  23. 1 0
      apps/app/src/server/models/obsolete-page.js
  24. 0 31
      apps/app/src/styles/_tag.scss
  25. 18 0
      apps/app/src/styles/atoms/_tag.scss
  26. 1 1
      apps/app/src/styles/style-app.scss
  27. 55 50
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--comments.cy.ts
  28. 1 1
      packages/core/scss/bootstrap/apply.scss
  29. 59 0
      packages/core/scss/bootstrap/mixins/_button-outline-neutral-variant.scss
  30. 14 0
      packages/core/scss/bootstrap/override/_buttons.scss
  31. 12 0
      packages/core/scss/bootstrap/override/_helpers.scss
  32. 8 0
      packages/core/scss/bootstrap/override/helpers/_color-bg.scss
  33. 9 9
      packages/core/scss/bootstrap/theming/_variables.scss
  34. 1 1
      packages/core/scss/bootstrap/utilities.scss
  35. 6 0
      packages/editor/package.json
  36. 21 1
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  37. 3 1
      packages/editor/src/components/CodeMirrorEditorMain.tsx
  38. 1 0
      packages/editor/src/components/index.ts
  39. 3 1
      packages/editor/src/components/playground/Playground.tsx
  40. 49 1
      packages/editor/src/components/playground/PlaygroundController.tsx
  41. 80 0
      packages/editor/src/services/editor-theme/ayu.ts
  42. 86 0
      packages/editor/src/services/editor-theme/cobalt.ts
  43. 26 0
      packages/editor/src/services/editor-theme/index.ts
  44. 32 0
      packages/editor/src/services/editor-theme/original-dark.ts
  45. 35 0
      packages/editor/src/services/editor-theme/original-light.ts
  46. 60 0
      packages/editor/src/services/editor-theme/rose-pine.ts
  47. 1 0
      packages/editor/src/services/index.ts
  48. 3 0
      packages/preset-themes/src/consts/preset-themes.ts
  49. 67 0
      packages/preset-themes/src/styles/classic.scss
  50. 1 0
      packages/preset-themes/vite.themes.config.ts
  51. 38 0
      yarn.lock

+ 1 - 3
apps/app/src/components/ItemsTree/ItemsTree.module.scss

@@ -65,9 +65,7 @@ $grw-pagetree-item-container-height: 40px;
 
     .grw-pagetree-item-container {
       .grw-triangle-container {
-        // TODO: ignore width frickering
-        // https://redmine.weseek.co.jp/issues/130828
-        // min-width: 35px;
+        min-width: 35px;
         height: $grw-pagetree-item-container-height;
       }
     }

+ 0 - 15
apps/app/src/components/Me/ColorModeSettings.module.scss

@@ -1,15 +0,0 @@
-@use '@growi/core/scss/bootstrap/init' as *;
-
-.color-settings :global {
-  .btn {
-    font-weight: bold;
-    color: var(--color-global);
-    background-color: transparent;
-    border-width: 3px;
-  }
-
-  .btn-outline-secondary {
-    border-color: $gray-400;
-  }
-}
-

+ 34 - 26
apps/app/src/components/Me/ColorModeSettings.tsx

@@ -4,7 +4,27 @@ import { useTranslation } from 'react-i18next';
 
 import { Themes, useNextThemes } from '~/stores/use-next-themes';
 
-import styles from './ColorModeSettings.module.scss';
+// import styles from './ColorModeSettings.module.scss';
+
+
+type ColorModeSettingsButtonProps = {
+  isActive: boolean,
+  children?: React.ReactNode,
+  onClick?: () => void,
+}
+
+const ColorModeSettingsButton = ({ isActive, children, onClick }: ColorModeSettingsButtonProps): JSX.Element => {
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      className={`btn py-2 px-4 fw-bold border-3 ${isActive ? 'btn-outline-primary' : 'btn-outline-neutral-secondary'}`}
+    >
+      { children }
+    </button>
+  );
+};
+
 
 export const ColorModeSettings = (): JSX.Element => {
   const { t } = useTranslation();
@@ -16,40 +36,28 @@ export const ColorModeSettings = (): JSX.Element => {
   }, [theme]);
 
   return (
-    <div className={`color-settings ${styles['color-settings']}`}>
+    <div>
       <h2 className="border-bottom mb-4">{t('color_mode_settings.settings')}</h2>
 
       <div className="offset-md-3">
-        <div className="d-flex">
-          <button
-            type="button"
-            onClick={() => { setTheme(Themes.LIGHT) }}
-            // eslint-disable-next-line max-len
-            className={`btn py-2 px-4 me-4 d-flex align-items-center justify-content-center ${isActive(Themes.LIGHT) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
-          >
+
+        <div className="d-flex column-gap-3">
+
+          <ColorModeSettingsButton isActive={isActive(Themes.LIGHT)} onClick={() => { setTheme(Themes.LIGHT) }}>
             <span className="material-symbols-outlined fs-5 me-1">light_mode</span>
             <span>{t('color_mode_settings.light')}</span>
-          </button>
-
-          <button
-            type="button"
-            onClick={() => { setTheme(Themes.DARK) }}
-            // eslint-disable-next-line max-len
-            className={`btn py-2 px-4 me-4 d-flex align-items-center justify-content-center ${isActive(Themes.DARK) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
-          >
+          </ColorModeSettingsButton>
+
+          <ColorModeSettingsButton isActive={isActive(Themes.DARK)} onClick={() => { setTheme(Themes.DARK) }}>
             <span className="material-symbols-outlined fs-5 me-1">dark_mode</span>
             <span>{t('color_mode_settings.dark')}</span>
-          </button>
-
-          <button
-            type="button"
-            onClick={() => { setTheme(Themes.SYSTEM) }}
-            // eslint-disable-next-line max-len
-            className={`btn py-2 px-4 d-flex align-items-center justify-content-center ${isActive(Themes.SYSTEM) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
-          >
+          </ColorModeSettingsButton>
+
+          <ColorModeSettingsButton isActive={isActive(Themes.SYSTEM)} onClick={() => { setTheme(Themes.SYSTEM) }}>
             <span className="material-symbols-outlined fs-5 me-1">devices</span>
             <span>{t('color_mode_settings.system')}</span>
-          </button>
+          </ColorModeSettingsButton>
+
         </div>
 
         <div className="mt-3 text-muted">

+ 37 - 11
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -2,7 +2,9 @@ import React, {
   useCallback, useState, useRef, useEffect,
 } from 'react';
 
-import { useResolvedThemeForEditor } from '@growi/editor';
+import {
+  CodeMirrorEditorComment, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
+} from '@growi/editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
@@ -79,7 +81,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     decrement: decrementEditingCommentsNum,
   } = useSWRxEditingCommentsNum();
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
-
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.COMMENT);
   const { resolvedTheme } = useNextThemes();
   mutateResolvedTheme(resolvedTheme);
 
@@ -143,6 +145,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     if (editingCommentsNum != null && editingCommentsNum === 0) {
       mutateIsEnabledUnsavedWarning(false); // must be after clearing comment or else onChange will override bool
     }
+
   }, [initializeSlackEnabled, comment, decrementEditingCommentsNum, mutateIsEnabledUnsavedWarning]);
 
   const cancelButtonClickedHandler = useCallback(() => {
@@ -186,15 +189,17 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       if (onCommentButtonClicked != null) {
         onCommentButtonClicked();
       }
+
+      // Insert empty string as new comment editor is opened after comment
+      codeMirrorEditor?.initDoc('');
     }
     catch (err) {
       const errorMessage = err.message || 'An unknown error occured when posting comment';
       setError(errorMessage);
     }
   }, [
-    comment, currentCommentId, initializeEditor,
-    isSlackEnabled, onCommentButtonClicked, replyTo, slackChannels,
-    postComment, revisionId, updateComment,
+    currentCommentId, initializeEditor, onCommentButtonClicked, codeMirrorEditor,
+    updateComment, comment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment,
   ]);
 
   const ctrlEnterHandler = useCallback((event) => {
@@ -267,14 +272,32 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     );
   }, []);
 
-  const onChangeHandler = useCallback((newValue: string, isClean: boolean) => {
+  // const onChangeHandler = useCallback((newValue: string, isClean: boolean) => {
+  //   setComment(newValue);
+  //   if (!isClean && !incremented) {
+  //     incrementEditingCommentsNum();
+  //     setIncremented(true);
+  //   }
+  //   mutateIsEnabledUnsavedWarning(!isClean);
+  // }, [mutateIsEnabledUnsavedWarning, incrementEditingCommentsNum, incremented]);
+
+  const onChangeHandler = useCallback((newValue: string) => {
     setComment(newValue);
-    if (!isClean && !incremented) {
+
+    if (!incremented) {
       incrementEditingCommentsNum();
       setIncremented(true);
     }
-    mutateIsEnabledUnsavedWarning(!isClean);
-  }, [mutateIsEnabledUnsavedWarning, incrementEditingCommentsNum, incremented]);
+  }, [incrementEditingCommentsNum, incremented]);
+
+  // initialize CodeMirrorEditor
+  useEffect(() => {
+    if (commentBody == null) {
+      return;
+    }
+    codeMirrorEditor?.initDoc(commentBody);
+  }, [codeMirrorEditor, commentBody]);
+
 
   const renderReady = () => {
     const commentPreview = getCommentHtml();
@@ -311,7 +334,10 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
           <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
           <TabContent activeTab={activeTab}>
             <TabPane tabId="comment_editor">
-              <Editor
+              <CodeMirrorEditorComment
+                onChange={onChangeHandler}
+              />
+              {/* <Editor
                 ref={editorRef}
                 value={commentBody ?? ''} // DO NOT use state
                 isUploadable={isUploadable}
@@ -320,7 +346,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
                 onUpload={uploadHandler}
                 onCtrlEnter={ctrlEnterHandler}
                 isComment
-              />
+              /> */}
               {/*
                 Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
                 See a review comment in https://github.com/weseek/growi/pull/3473

+ 66 - 55
apps/app/src/components/PageEditor/OptionsSelector.tsx

@@ -14,7 +14,7 @@ import { DEFAULT_THEME, KeyMapMode } from '../../interfaces/editor-settings';
 
 
 const AVAILABLE_THEMES = [
-  'eclipse', 'elegant', 'neo', 'mdn-like', 'material', 'dracula', 'monokai', 'twilight',
+  'DefaultLight', 'Eclipse', 'Basic', 'Ayu', 'Rosé Pine', 'DefaultDark', 'Material', 'Nord', 'Cobalt', 'Kimbie',
 ];
 
 const TYPICAL_INDENT_SIZE = [2, 4];
@@ -22,12 +22,18 @@ const TYPICAL_INDENT_SIZE = [2, 4];
 
 const ThemeSelector = (): JSX.Element => {
 
+  const [isThemeMenuOpened, setIsThemeMenuOpened] = useState(false);
+
   const { data: editorSettings, update } = useEditorSettings();
 
   const menuItems = useMemo(() => (
     <>
       { AVAILABLE_THEMES.map((theme) => {
-        return <button key={theme} className="dropdown-item" type="button" onClick={() => update({ theme })}>{theme}</button>;
+        return (
+          <DropdownItem className="menuitem-label" onClick={() => update({ theme })}>
+            {theme}
+          </DropdownItem>
+        );
       }) }
     </>
   ), [update]);
@@ -39,21 +45,21 @@ const ThemeSelector = (): JSX.Element => {
       <div>
         <span className="input-group-text" id="igt-theme">Theme</span>
       </div>
-      <div className="dropup">
-        <button
-          type="button"
-          className="btn btn-outline-secondary dropdown-toggle"
-          data-bs-toggle="dropdown"
-          aria-haspopup="true"
-          aria-expanded="false"
-          aria-describedby="igt-theme"
-        >
+
+      <Dropdown
+        direction="up"
+        isOpen={isThemeMenuOpened}
+        toggle={() => setIsThemeMenuOpened(!isThemeMenuOpened)}
+      >
+        <DropdownToggle color="outline-secondary" caret>
           {selectedTheme}
-        </button>
-        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+        </DropdownToggle>
+
+        <DropdownMenu container="body">
           {menuItems}
-        </div>
-      </div>
+        </DropdownMenu>
+
+      </Dropdown>
     </div>
   );
 };
@@ -72,9 +78,10 @@ const KEYMAP_LABEL_MAP: KeyMapModeToLabel = {
 
 const KeymapSelector = memo((): JSX.Element => {
 
+  const [isKeyMenuOpened, setIsKeyMenuOpened] = useState(false);
+
   const { data: editorSettings, update } = useEditorSettings();
 
-  Object.keys(KEYMAP_LABEL_MAP);
   const menuItems = useMemo(() => (
     <>
       { (Object.keys(KEYMAP_LABEL_MAP) as KeyMapMode[]).map((keymapMode) => {
@@ -82,7 +89,11 @@ const KeymapSelector = memo((): JSX.Element => {
         const icon = (keymapMode !== 'default')
           ? <img src={`/images/icons/${keymapMode}.png`} width="16px" className="me-2"></img>
           : null;
-        return <button key={keymapMode} className="dropdown-item" type="button" onClick={() => update({ keymapMode })}>{icon}{keymapLabel}</button>;
+        return (
+          <DropdownItem className="menuitem-label" onClick={() => update({ keymapMode })}>
+            {icon}{keymapLabel}
+          </DropdownItem>
+        );
       }) }
     </>
   ), [update]);
@@ -91,24 +102,21 @@ const KeymapSelector = memo((): JSX.Element => {
 
   return (
     <div className="input-group flex-nowrap">
-      <div>
-        <span className="input-group-text" id="igt-keymap">Keymap</span>
-      </div>
-      <div className="dropup">
-        <button
-          type="button"
-          className="btn btn-outline-secondary dropdown-toggle"
-          data-bs-toggle="dropdown"
-          aria-haspopup="true"
-          aria-expanded="false"
-          aria-describedby="igt-keymap"
-        >
-          { editorSettings != null && selectedKeymapMode}
-        </button>
-        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+      <span className="input-group-text" id="igt-keymap">Keymap</span>
+      <Dropdown
+        direction="up"
+        isOpen={isKeyMenuOpened}
+        toggle={() => setIsKeyMenuOpened(!isKeyMenuOpened)}
+      >
+        <DropdownToggle color="outline-secondary" caret>
+          {selectedKeymapMode}
+        </DropdownToggle>
+
+        <DropdownMenu container="body">
           {menuItems}
-        </div>
-      </div>
+        </DropdownMenu>
+
+      </Dropdown>
     </div>
   );
 
@@ -123,38 +131,41 @@ type IndentSizeSelectorProps = {
 }
 
 const IndentSizeSelector = memo(({ isIndentSizeForced, selectedIndentSize, onChange }: IndentSizeSelectorProps): JSX.Element => {
+
+  const [isIndentMenuOpened, setIsIndentMenuOpened] = useState(false);
+
   const menuItems = useMemo(() => (
     <>
       { TYPICAL_INDENT_SIZE.map((indent) => {
-        return <button key={indent} className="dropdown-item" type="button" onClick={() => onChange(indent)}>{indent}</button>;
+        return (
+          <DropdownItem className="menuitem-label" onClick={() => onChange(indent)}>
+            {indent}
+          </DropdownItem>
+        );
       }) }
     </>
   ), [onChange]);
 
   return (
     <div className="input-group flex-nowrap">
-      <div>
-        <span className="input-group-text" id="igt-indent">Indent</span>
-      </div>
-      <div className="dropup">
-        <button
-          type="button"
-          className="btn btn-outline-secondary dropdown-toggle"
-          data-bs-toggle="dropdown"
-          aria-haspopup="true"
-          aria-expanded="false"
-          aria-describedby="igt-indent"
-          disabled={isIndentSizeForced}
-        >
+      <span className="input-group-text" id="igt-indent">Indent</span>
+      <Dropdown
+        direction="up"
+        isOpen={isIndentMenuOpened}
+        toggle={() => setIsIndentMenuOpened(!isIndentMenuOpened)}
+        disabled={isIndentSizeForced}
+      >
+        <DropdownToggle color="outline-secondary" caret>
           {selectedIndentSize}
-        </button>
-        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+        </DropdownToggle>
+
+        <DropdownMenu container="body">
           {menuItems}
-        </div>
-      </div>
+        </DropdownMenu>
+
+      </Dropdown>
     </div>
   );
-
 });
 
 IndentSizeSelector.displayName = 'IndentSizeSelector';
@@ -228,7 +239,7 @@ const ConfigurationDropdown = memo((): JSX.Element => {
           <i className="icon-settings"></i>
         </DropdownToggle>
 
-        <DropdownMenu>
+        <DropdownMenu container="body">
           {renderActiveLineMenuItem()}
           {renderMarkdownTableAutoFormattingMenuItem()}
           {/* <DropdownItem divider /> */}
@@ -254,7 +265,7 @@ export const OptionsSelector = (): JSX.Element => {
 
   return (
     <>
-      <div className="d-flex flex-row">
+      <div className="d-flex flex-row zindex-dropdown">
         <span>
           <ThemeSelector />
         </span>

+ 4 - 0
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 { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
@@ -28,6 +29,7 @@ import {
   useIsEditable, useIsUploadAllFileAllowed, useIsUploadEnabled, useIsIndentSizeForced,
 } from '~/stores/context';
 import {
+  useEditorSettings,
   useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
   useIsConflict,
@@ -112,6 +114,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
   const { data: isUploadEnabled } = useIsUploadEnabled();
   const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
+  const { data: editorSettings } = useEditorSettings();
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
   const { mutate: mutateRemotePageId } = useRemoteRevisionId();
   const { mutate: mutateRemoteRevisionId } = useRemoteRevisionBody();
@@ -501,6 +504,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             onScroll={scrollEditorHandlerThrottle}
             indentSize={currentIndentSize ?? defaultIndentSize}
             acceptedFileType={acceptedFileType}
+            editorTheme={editorSettings?.theme}
           />
         </div>
         <div ref={previewRef} onScroll={scrollPreviewHandlerThrottle} className="page-editor-preview-container flex-expand-vert d-none d-lg-flex">

+ 5 - 4
apps/app/src/components/PageSelectModal/TreeItemForModal.tsx

@@ -1,7 +1,7 @@
 import React, { type FC } from 'react';
 
 import {
-  SimpleItem, SimpleItemTool, useNewPageInput, type TreeItemProps,
+  SimpleItem, useNewPageInput, type TreeItemProps,
 } from '../TreeItem';
 
 type PageTreeItemProps = TreeItemProps & {
@@ -10,6 +10,7 @@ type PageTreeItemProps = TreeItemProps & {
 
 export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
 
+  const { isOpen } = props;
   const { Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
 
   return (
@@ -17,15 +18,15 @@ export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
       key={props.key}
       targetPathOrId={props.targetPathOrId}
       itemNode={props.itemNode}
-      isOpen
+      isOpen={isOpen}
       isEnableActions={props.isEnableActions}
       isReadOnlyUser={props.isReadOnlyUser}
-      onRenamed={props.onRenamed}
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}
+      onRenamed={props.onRenamed}
       customNextComponents={[NewPageInput]}
       itemClass={TreeItemForModal}
-      customEndComponents={[SimpleItemTool, NewPageCreateButton]}
+      customEndComponents={[NewPageCreateButton]}
     />
   );
 };

+ 2 - 2
apps/app/src/components/PageSideContents/PageAccessoriesControl.tsx

@@ -26,7 +26,7 @@ export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
   return (
     <button
       type="button"
-      className={`btn btn-sm btn-outline-secondary ${moduleClass} ${className} rounded-pill`}
+      className={`btn btn-sm btn-outline-neutral-secondary ${moduleClass} ${className} rounded-pill`}
       onClick={onClick}
     >
       <span className="grw-icon d-flex">{icon}</span>
@@ -34,7 +34,7 @@ export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
         {label}
         {/* Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600 */}
         { count != null
-          ? <CountBadge count={count} offset={1} />
+          ? <CountBadge count={count} />
           : <div className="px-2"></div>}
       </span>
     </button>

+ 1 - 1
apps/app/src/components/PageTags/RenderTagLabels.tsx

@@ -30,7 +30,7 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
           <a
             key={tag}
             type="button"
-            className="grw-tag-label badge bg-primary me-2"
+            className="grw-tag badge me-2"
             onClick={() => pushState(`tag:${tag}`)}
           >
             {tag}

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

@@ -1,9 +1,11 @@
 import React from 'react';
 
 import { useTranslation } from 'react-i18next';
+import { DropdownMenu, DropdownItem } from 'reactstrap';
 
 import { LabelType } from '~/interfaces/template';
 
+
 type DropendMenuProps = {
   onClickCreateNewPageButtonHandler: () => Promise<void>
   onClickCreateTodaysButtonHandler: () => Promise<void>
@@ -22,52 +24,38 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
   const { t } = useTranslation('commons');
 
   return (
-    <ul className="dropdown-menu">
-      <li>
-        <button
-          className="dropdown-item"
-          onClick={onClickCreateNewPageButtonHandler}
-          type="button"
-        >
-          {t('create_page_dropdown.new_page')}
-        </button>
-      </li>
+    <DropdownMenu
+      container="body"
+    >
+      <DropdownItem
+        onClick={onClickCreateNewPageButtonHandler}
+      >
+        {t('create_page_dropdown.new_page')}
+      </DropdownItem>
       {todaysPath != null && (
         <>
-          <li><hr className="dropdown-divider" /></li>
+          <DropdownItem divider />
           <li><span className="text-muted px-3">{t('create_page_dropdown.todays.desc')}</span></li>
-          <li>
-            <button
-              className="dropdown-item"
-              onClick={onClickCreateTodaysButtonHandler}
-              type="button"
-            >
-              {todaysPath}
-            </button>
-          </li>
+          <DropdownItem
+            onClick={onClickCreateTodaysButtonHandler}
+          >
+            {todaysPath}
+          </DropdownItem>
         </>
       )}
-      <li><hr className="dropdown-divider" /></li>
+      <DropdownItem divider />
       <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
-      <li>
-        <button
-          className="dropdown-item"
-          onClick={() => onClickTemplateButtonHandler('_template')}
-          type="button"
-        >
-          {t('create_page_dropdown.template.children')}
-        </button>
-      </li>
-      <li>
-        <button
-          className="dropdown-item"
-          onClick={() => onClickTemplateButtonHandler('__template')}
-          type="button"
-        >
-          {t('create_page_dropdown.template.descendants')}
-        </button>
-      </li>
-    </ul>
+      <DropdownItem
+        onClick={() => onClickTemplateButtonHandler('_template')}
+      >
+        {t('create_page_dropdown.template.children')}
+      </DropdownItem>
+      <DropdownItem
+        onClick={() => onClickTemplateButtonHandler('__template')}
+      >
+        {t('create_page_dropdown.template.descendants')}
+      </DropdownItem>
+    </DropdownMenu>
   );
 });
 DropendMenu.displayName = 'DropendMenu';

+ 8 - 9
apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx

@@ -1,24 +1,23 @@
-import type { ButtonHTMLAttributes, DetailedHTMLProps } from 'react';
+import { DropdownToggle } from 'reactstrap';
 
 import { Hexagon } from './Hexagon';
 
 import styles from './DropendToggle.module.scss';
 
-const moduleClass = styles['btn-toggle'];
 
+const moduleClass = styles['btn-toggle'];
 
-type Props = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
 
-export const DropendToggle = (props: Props): JSX.Element => {
+export const DropendToggle = (): JSX.Element => {
   return (
-    <button
-      type="button"
-      {...props}
-      className={`${moduleClass} btn btn-primary ${props.className ?? ''}`}
+    <DropdownToggle
+      color="primary"
+      className={`position-absolute ${moduleClass}`}
+      aria-expanded={false}
     >
       <Hexagon />
       <div className="hitarea position-absolute" />
       <span className="icon material-symbols-outlined position-absolute">chevron_right</span>
-    </button>
+    </DropdownToggle>
   );
 };

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

@@ -4,6 +4,7 @@ import type { IUserHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
+import { Dropdown } from 'reactstrap';
 
 import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
 import { toastError } from '~/client/util/toastr';
@@ -16,6 +17,7 @@ import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
 import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
 
+
 const generateTodaysPath = (currentUser: IUserHasId, parentDirName: string) => {
   const now = format(new Date(), 'yyyy/MM/dd');
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
@@ -31,6 +33,8 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
   const [isHovered, setIsHovered] = useState(false);
 
+  const [dropdownOpen, setDropdownOpen] = useState(false);
+
   const todaysPath = currentUser == null
     ? null
     : generateTodaysPath(currentUser, t('create_page_dropdown.todays.memo'));
@@ -58,8 +62,11 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
   const onMouseLeaveHandler = () => {
     setIsHovered(false);
+    setDropdownOpen(false);
   };
 
+  const toggle = () => setDropdownOpen(!dropdownOpen);
+
   return (
     <div
       className="d-flex flex-row"
@@ -74,19 +81,20 @@ export const PageCreateButton = React.memo((): JSX.Element => {
         />
       </div>
       { isHovered && (
-        <div className="btn-group dropend position-absolute">
-          <DropendToggle
-            className="dropdown-toggle dropdown-toggle-split"
-            data-bs-toggle="dropdown"
-            aria-expanded="false"
-          />
+        <Dropdown
+          isOpen={dropdownOpen}
+          toggle={toggle}
+          direction="end"
+          className="position-absolute"
+        >
+          <DropendToggle />
           <DropendMenu
             onClickCreateNewPageButtonHandler={onClickNewButton}
             onClickCreateTodaysButtonHandler={onClickTodaysButton}
             onClickTemplateButtonHandler={onClickTemplateButtonHandler}
             todaysPath={todaysPath}
           />
-        </div>
+        </Dropdown>
       )}
     </div>
   );

+ 0 - 2
apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -45,8 +45,6 @@ const PageTreeUnavailable = () => {
 };
 
 export const PageTreeContent = memo(() => {
-  const { t } = useTranslation();
-
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();

+ 19 - 3
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -6,12 +6,14 @@ import React, {
 import nodePath from 'path';
 
 import type { IPageHasId } from '@growi/core';
-import { pagePathUtils } from '@growi/core/dist/utils';
+import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 import { useDrag, useDrop } from 'react-dnd';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastWarning, toastError } from '~/client/util/toastr';
+import type { IPageForItem } from '~/interfaces/page';
 import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
@@ -21,9 +23,12 @@ import {
 
 import { Ellipsis } from './Ellipsis';
 
+
 const logger = loggerFactory('growi:cli:Item');
 
 export const PageTreeItem: FC<TreeItemProps> = (props) => {
+  const router = useRouter();
+
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
     const pageTitle = nodePath.basename(droppedPagePath);
     return nodePath.join(newParentPagePath, pageTitle);
@@ -53,6 +58,16 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
 
   const { mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
 
+  const itemSelectedHandler = useCallback((page: IPageForItem) => {
+    if (page.path == null || page._id == null) {
+      return;
+    }
+
+    const link = pathUtils.returnPathForURL(page.path, page._id);
+
+    router.push(link);
+  }, [router]);
+
   const displayDroppedItemByPageId = useCallback((pageId) => {
     const target = document.getElementById(`pagetree-item-${pageId}`);
     if (target == null) {
@@ -158,12 +173,13 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
     <SimpleItem
       targetPathOrId={props.targetPathOrId}
       itemNode={props.itemNode}
-      isOpen
+      isOpen={isOpen}
       isEnableActions={props.isEnableActions}
       isReadOnlyUser={props.isReadOnlyUser}
-      onRenamed={props.onRenamed}
+      onClick={itemSelectedHandler}
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}
+      onRenamed={props.onRenamed}
       itemRef={itemRef}
       itemClass={PageTreeItem}
       mainClassName={mainClassName}

+ 30 - 21
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss

@@ -6,7 +6,7 @@
   transform: translateY(-2px);
 
   .form-check-label::before {
-    padding-left: 19px;
+    padding-left: 5px;
     content: 'L';
   }
 
@@ -17,6 +17,8 @@
 }
 
 .list-group-item :global {
+  font-size: 12px;
+
   .grw-recent-changes-skeleton-small {
     @include grw-skeleton-text($font-size:14px, $line-height:16px);
     max-width: 120px;
@@ -32,26 +34,6 @@
     width: 80px;
   }
 
-  .grw-recent-changes-item-lower {
-    height: 17.5px;
-  }
-  .footstamp-icon {
-    svg {
-      width: 14px;
-      height: 14px;
-      transform: translateY(-3.5px);
-    }
-  }
-
-  .grw-list-counts {
-    height: 14px;
-    font-size: 12px;
-  }
-
-  .grw-formatted-distance-date {
-    font-size: 10px;
-  }
-
   .icon-lock {
     font-size: 14px;
   }
@@ -65,3 +47,30 @@
     max-width: fit-content;
   }
 }
+
+
+.grw-recent-changes-item-lower :global {
+  font-size: 12px;
+
+  .material-symbols-outlined {
+    font-size: 14px;
+  }
+  .grw-formatted-distance-date {
+    font-size: 10px;
+  }
+}
+
+// == Colors
+.grw-former-link a {
+  --bs-link-opacity: .5;
+
+  &:global {
+    &:hover {
+      --bs-link-opacity: 1;
+    }
+  }
+}
+
+.grw-recent-changes-item-lower :global {
+  color: var(--bs-gray-500);
+}

+ 73 - 37
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -2,9 +2,11 @@ import React, {
   memo, useCallback, useEffect,
 } from 'react';
 
-import { isPopulated, type IPageHasId } from '@growi/core';
+import {
+  isPopulated, type IPageHasId,
+} from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
-import { UserPicture, FootstampIcon } from '@growi/ui/dist/components';
+import { UserPicture } from '@growi/ui/dist/components';
 
 import { useKeywordManager } from '~/client/services/search-operation';
 import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
@@ -18,6 +20,8 @@ import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 
 import styles from './RecentChangesSubstance.module.scss';
 
+const formerLinkClass = styles['grw-former-link'];
+const pageItemLowerClass = styles['grw-recent-changes-item-lower'];
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:History');
@@ -33,14 +37,18 @@ type PageItemProps = PageItemLowerProps & {
 
 const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
   return (
-    <div className="d-flex justify-content-between grw-recent-changes-item-lower pt-1">
-      <div className="d-flex">
-        <div className="footstamp-icon me-1 d-inline-block"><FootstampIcon /></div>
-        <div className="me-2 grw-list-counts d-inline-block">{page.seenUsers.length}</div>
-        <div className="icon-bubble me-1 d-inline-block"></div>
-        <div className="me-2 grw-list-counts d-inline-block">{page.commentCount}</div>
+    <div className={`${pageItemLowerClass} d-flex justify-content-between grw-recent-changes-item-lower`}>
+      <div className="d-flex align-items-center">
+        <div className="">
+          <span className="material-symbols-outlined p-0">footprint</span>
+          <span className="grw-list-counts ms-1">{page.seenUsers.length}</span>
+        </div>
+        <div className="ms-2">
+          <span className="material-symbols-outlined p-0">chat</span>
+          <span className="grw-list-counts ms-1">{page.commentCount}</span>
+        </div>
       </div>
-      <div className="grw-formatted-distance-date small mt-auto" data-vrt-blackout-datetime>
+      <div className="grw-formatted-distance-date mt-auto" data-vrt-blackout-datetime>
         <FormattedDistanceDate id={page._id} date={page.updatedAt} />
       </div>
     </div>
@@ -48,12 +56,42 @@ const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
 });
 PageItemLower.displayName = 'PageItemLower';
 
+type PageTagsProps = PageItemProps;
+const PageTags = memo((props: PageTagsProps): JSX.Element => {
+  const { page, isSmall, onClickTag } = props;
+
+  if (isSmall || (page.tags.length === 0)) {
+    return <></>;
+  }
+
+  return (
+    <>
+      { page.tags.map((tag) => {
+        if (!isPopulated(tag)) {
+          return <></>;
+        }
+        return (
+          <a
+            key={tag.name}
+            type="button"
+            className="grw-tag badge me-2"
+            onClick={() => onClickTag?.(tag.name)}
+          >
+            {tag.name}
+          </a>
+        );
+      }) }
+    </>
+  );
+});
+PageTags.displayName = 'PageTags';
+
 const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Element => {
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
   const FormerLink = () => (
-    <div className="small">
+    <div className={`${formerLinkClass} small`}>
       <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
     </div>
   );
@@ -63,39 +101,37 @@ const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Elemen
     locked = <span><i className="icon-lock ms-2" /></span>;
   }
 
-  const tags = page.tags;
-  const tagElements = tags.map((tag) => {
-    if (!isPopulated(tag)) {
-      return <></>;
-    }
-    return (
-      <a
-        key={tag.name}
-        type="button"
-        className="grw-tag-label badge bg-primary me-2 small"
-        onClick={() => onClickTag?.(tag.name)}
-      >
-        {tag.name}
-      </a>
-    );
-  });
+  const isTagElementsRendered = !(isSmall || (page.tags.length === 0));
 
   return (
-    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
+    <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
       <div className="d-flex w-100">
+
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
+
         <div className="flex-grow-1 ms-2">
-          { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className={isSmall ? 'my-0 text-truncate' : 'my-2'}>
-            <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
-            {locked}
-          </h5>
-          {!isSmall && (
-            <div className="grw-tag-labels mt-1 mb-2">
-              { tagElements }
+          <div className={`row ${isSmall ? 'gy-0' : 'gy-2'}`}>
+
+            <div className="col-12">
+              { !dPagePath.isRoot && <FormerLink /> }
             </div>
-          )}
-          <PageItemLower page={page} />
+
+            <h6 className={`col-12 ${isSmall ? 'mb-0 text-truncate' : 'mb-0'}`}>
+              <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
+              {locked}
+            </h6>
+
+            { isTagElementsRendered && (
+              <div className="col-12">
+                <PageTags isSmall={isSmall} page={page} onClickTag={onClickTag} />
+              </div>
+            ) }
+
+            <div className="col-12">
+              <PageItemLower page={page} />
+            </div>
+
+          </div>
         </div>
       </div>
     </li>

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

@@ -81,9 +81,7 @@ const Tag: FC = () => {
 
       <h3 className="my-3">{t('popular_tags')}</h3>
 
-      <div className="text-center">
-        <TagCloudBox tags={tagCloudData} />
-      </div>
+      <TagCloudBox tags={tagCloudData} />
     </div>
   );
 

+ 2 - 2
apps/app/src/components/TagCloudBox.tsx

@@ -31,7 +31,7 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
       <a
         key={tag.name}
         type="button"
-        className="grw-tag-label badge bg-primary me-2"
+        className="grw-tag badge me-2"
         onClick={() => pushState(`tag:${tag.name}`)}
       >
         {tagNameFormat}
@@ -40,7 +40,7 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
   });
 
   return (
-    <div className="grw-popular-tag-labels">
+    <div>
       {tagElements}
     </div>
   );

+ 27 - 0
apps/app/src/components/TagList.module.scss

@@ -0,0 +1,27 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+.grw-tag-list :global {
+  .list-group {
+    // remove border
+    --bs-border-width: 0;
+  }
+}
+
+
+// == Colors
+@include bs.color-mode(light) {
+  .grw-tag-list :global {
+    .grw-tag-count {
+      color: var(--bs-gray-600);
+      background-color: var(--grw-highlight-100);
+    }
+  }
+}
+@include bs.color-mode(dark) {
+  .grw-tag-list :global {
+    .grw-tag-count {
+      color: var(--bs-gray-500);
+      background-color: var(--grw-highlight-800);
+    }
+  }
+}

+ 11 - 6
apps/app/src/components/TagList.tsx

@@ -9,6 +9,11 @@ import { IDataTagCount } from '~/interfaces/tag';
 
 import PaginationWrapper from './PaginationWrapper';
 
+import styles from './TagList.module.scss';
+
+const moduleClass = styles['grw-tag-list'];
+
+
 type TagListProps = {
   tagData: IDataTagCount[],
   totalTags: number,
@@ -37,11 +42,11 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
         <button
           key={tag._id}
           type="button"
-          className="list-group-item list-group-item-action d-flex"
+          className="list-group-item list-group-item-action d-flex justify-content-between"
           onClick={() => pushState(`tag:${tag.name}`)}
         >
-          <div className="text-truncate list-tag-name">{tag.name}</div>
-          <div className="ms-4 my-auto py-1 px-2 list-tag-count badge bg-primary">{tag.count}</div>
+          <div className="text-truncate grw-tag badge">{tag.name}</div>
+          <div className="grw-tag-count badge">{tag.count}</div>
         </button>
       );
     });
@@ -52,8 +57,8 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
   }
 
   return (
-    <>
-      <div className="list-group text-start mb-5">
+    <div className={moduleClass}>
+      <div className="list-group list-group-flush mb-5">
         {generateTagList(tagData)}
       </div>
       {isPaginationShown
@@ -68,7 +73,7 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
         />
       )
       }
-    </>
+    </div>
   );
 
 };

+ 43 - 33
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -1,16 +1,15 @@
 import React, {
   useCallback, useState, useEffect,
-  type FC, type RefObject, type RefCallback,
+  type FC, type RefObject, type RefCallback, type MouseEvent,
 } from 'react';
 
 import nodePath from 'path';
 
 import type { Nullable } from '@growi/core';
-import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import { UncontrolledTooltip } from 'reactstrap';
 
+import type { IPageForItem } from '~/interfaces/page';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import { shouldRecoverPagePaths } from '~/utils/page-operation';
@@ -40,34 +39,15 @@ const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): vo
 };
 
 
-export const SimpleItemTool: FC<TreeItemToolProps> = (props) => {
+const SimpleItemContent = ({ page }: { page: IPageForItem }) => {
   const { t } = useTranslation();
-  const router = useRouter();
-
-  const { getDescCount } = usePageTreeDescCountMap();
-
-  const { page } = props.itemNode;
 
   const pageName = nodePath.basename(page.path ?? '') || '/';
 
   const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
 
-  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-  const pageTreeItemClickHandler = (e) => {
-    e.preventDefault();
-
-    if (page.path == null || page._id == null) {
-      return;
-    }
-
-    const link = pathUtils.returnPathForURL(page.path, page._id);
-
-    router.push(link);
-  };
-
   return (
-    <>
+    <div className="flex-grow-1 d-flex align-items-center pe-none">
       {shouldShowAttentionIcon && (
         <>
           <i id="path-recovery" className="fa fa-warning mr-2 text-warning"></i>
@@ -78,9 +58,22 @@ export const SimpleItemTool: FC<TreeItemToolProps> = (props) => {
       )}
       {page != null && page.path != null && page._id != null && (
         <div className="grw-pagetree-title-anchor flex-grow-1">
-          <p onClick={pageTreeItemClickHandler} className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</p>
+          <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</p>
         </div>
       )}
+    </div>
+  );
+};
+
+export const SimpleItemTool: FC<TreeItemToolProps> = (props) => {
+  const { getDescCount } = usePageTreeDescCountMap();
+
+  const { page } = props.itemNode;
+
+  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
+
+  return (
+    <>
       {descendantCount > 0 && (
         <div className="grw-pagetree-count-wrapper">
           <CountBadge count={descendantCount} />
@@ -97,7 +90,7 @@ type SimpleItemProps = TreeItemProps & {
 export const SimpleItem: FC<SimpleItemProps> = (props) => {
   const {
     itemNode, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
+    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
     itemRef, itemClass, mainClassName,
   } = props;
 
@@ -110,11 +103,22 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
 
   const { data } = useSWRxPageChildren(isOpen ? page._id : null);
 
+
+  const itemClickHandler = useCallback((e: MouseEvent) => {
+    // DO NOT handle the event when e.currentTarget and e.target is different
+    if (e.target !== e.currentTarget) {
+      return;
+    }
+
+    onClick?.(page);
+
+  }, [onClick, page]);
+
+
   // descendantCount
   const { getDescCount } = usePageTreeDescCountMap();
   const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
 
-
   // hasDescendants flag
   const isChildrenLoaded = currentChildren?.length > 0;
   const hasDescendants = descendantCount > 0 || isChildrenLoaded;
@@ -123,7 +127,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
     return currentChildren != null && currentChildren.length > 0;
   }, [currentChildren]);
 
-  const onClickLoadChildren = useCallback(async() => {
+  const onClickLoadChildren = useCallback(() => {
     setIsOpen(!isOpen);
   }, [isOpen]);
 
@@ -155,9 +159,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
 
   const ItemClassFixed = itemClass ?? SimpleItem;
 
-  const CustomEndComponents = props.customEndComponents;
-
-  const SimpleItemContent = CustomEndComponents ?? [SimpleItemTool];
+  const EndComponents = props.customEndComponents ?? [SimpleItemTool];
 
   const baseProps: Omit<TreeItemProps, 'itemNode'> = {
     isEnableActions,
@@ -185,10 +187,13 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
     >
       <li
         ref={itemRef}
+        role="button"
         className={`list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center
         ${page.isTarget ? 'grw-pagetree-current-page-item' : ''}`}
         id={page.isTarget ? 'grw-pagetree-current-page-item' : `grw-pagetree-list-${page._id}`}
+        onClick={itemClickHandler}
       >
+
         <div className="grw-triangle-container d-flex justify-content-center">
           {hasDescendants && (
             <button
@@ -202,10 +207,14 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
             </button>
           )}
         </div>
-        {SimpleItemContent.map((ItemContent, index) => (
+
+        <SimpleItemContent page={page} />
+
+        {EndComponents.map((EndComponent, index) => (
           // eslint-disable-next-line react/no-array-index-key
-          <ItemContent key={index} {...toolProps} />
+          <EndComponent key={index} {...toolProps} />
         ))}
+
       </li>
 
       {CustomNextComponents?.map((UnderItemContent, index) => (
@@ -220,6 +229,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
             itemNode: node,
             itemClass,
             mainClassName,
+            onClick,
           };
 
           return (

+ 3 - 1
apps/app/src/components/TreeItem/interfaces/index.ts

@@ -1,6 +1,7 @@
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import type { Nullable } from 'vitest';
 
+import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForPageDuplicateModal } from '~/stores/modal';
 
 import type { ItemNode } from '../ItemNode';
@@ -9,9 +10,9 @@ type TreeItemBaseProps = {
   itemNode: ItemNode,
   isEnableActions: boolean,
   isReadOnlyUser: boolean,
-  onRenamed?(fromPath: string | undefined, toPath: string): void,
   onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void,
   onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void,
+  onRenamed?(fromPath: string | undefined, toPath: string): void,
   stateHandlers?: {
     isOpen: boolean,
     setIsOpen: React.Dispatch<React.SetStateAction<boolean>>,
@@ -27,4 +28,5 @@ export type TreeItemProps = TreeItemBaseProps & {
   mainClassName?: string,
   customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customNextComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
+  onClick?(page: IPageForItem): void,
 };

+ 1 - 0
apps/app/src/server/models/obsolete-page.js

@@ -2,6 +2,7 @@ import { PageGrant, GroupType } from '@growi/core';
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import escapeStringRegexp from 'escape-string-regexp';
 
+import { Comment } from '~/features/comment/server/models/comment';
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import loggerFactory from '~/utils/logger';

+ 0 - 31
apps/app/src/styles/_tag.scss

@@ -1,31 +0,0 @@
-@use '@growi/core/scss/bootstrap/init' as bs;
-
-.tags-page {
-  .list-tag-count {
-    background: rgba(0, 0, 0, 0.08);
-  }
-}
-
-.grw-popular-tag-labels {
-  text-align: left;
-
-  .grw-tag-label {
-    font-size: 10px;
-    font-weight: normal;
-    border-radius: bs.$border-radius;
-  }
-}
-
-#edit-tag-modal {
-  .form-control {
-    height: auto;
-  }
-}
-
-.grw-recent-changes {
-  .grw-tag-label {
-    font-size: 10px;
-    font-weight: normal;
-    border-radius: bs.$border-radius;
-  }
-}

+ 18 - 0
apps/app/src/styles/atoms/_tag.scss

@@ -0,0 +1,18 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+// == Colors
+@include bs.color-mode(light) {
+  .grw-tag.badge {
+    --bs-badge-color: var(--bs-gray-600);
+    background-color: var(--bs-gray-100);
+    border: 1px solid var(--bs-gray-300);
+  }
+}
+@include bs.color-mode(dark) {
+  .grw-tag.badge {
+    --bs-badge-color: var(--bs-gray-500);
+    background-color: var(--bs-gray-800);
+    border: 1px solid var(--bs-gray-600);
+  }
+}
+

+ 1 - 1
apps/app/src/styles/style-app.scss

@@ -7,6 +7,7 @@
 @import 'atoms/spinners';
 @import 'atoms/custom_control';
 @import 'atoms/code';
+@import 'atoms/tag';
 
 // molecules
 @import 'molecules/toastr';
@@ -25,7 +26,6 @@
 @import 'mirror_mode';
 @import 'modal';
 @import 'share-link';
-@import 'tag';
 @import 'installer';
 
 

+ 55 - 50
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--comments.cy.ts

@@ -24,7 +24,7 @@ context('Comment', () => {
       // until
       return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
     });
-    cy.get('.CodeMirror').should('be.visible');
+    cy.get('.cm-content').should('be.visible');
 
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
@@ -43,7 +43,7 @@ context('Comment', () => {
       return cy.get('.comment-write').then($elem => $elem.is(':visible'));
     });
 
-    cy.get('.CodeMirror').type(commetText);
+    cy.get('.cm-content').type(commetText);
     cy.getByTestid("comment-submit-button").eq(0).click();
 
     // Check update comment count
@@ -65,71 +65,76 @@ context('Comment', () => {
       return cy.get('.comment-write').then($elem => $elem.is(':visible'));
     });
 
-    cy.get('.CodeMirror').type(commetText);
+    cy.get('.cm-content').type(commetText);
     cy.getByTestid("comment-submit-button").eq(0).click();
 
+    // TODO : https://redmine.weseek.co.jp/issues/139431
     // Check update comment count
-    commentCount += 1
-    cy.getByTestid('page-comment-button').contains(commentCount);
-    cy.screenshot(`${ssPrefix}2-reply-comments`);
+    // commentCount += 1
+    // cy.getByTestid('page-comment-button').contains(commentCount);
+    // cy.screenshot(`${ssPrefix}2-reply-comments`);
   });
 
-  it('Successfully delete comments', () => {
+  // TODO:https://redmine.weseek.co.jp/issues/139467
+  // it('Successfully delete comments', () => {
 
-    cy.getByTestid('page-comment-button').click();
+  //   cy.getByTestid('page-comment-button').click();
 
-    cy.get('.page-comments').should('be.visible');
-    cy.getByTestid('comment-delete-button').eq(0).click({force: true});
-    cy.get('.modal-content').then($elem => $elem.is(':visible'));
-    cy.get('.modal-footer > button:nth-child(3)').click();
+  //   cy.get('.page-comments').should('be.visible');
+  //   cy.getByTestid('comment-delete-button').eq(0).click({force: true});
+  //   cy.get('.modal-content').then($elem => $elem.is(':visible'));
+  //   cy.get('.modal-footer > button:nth-child(3)').click();
 
-    // Check update comment count
-    commentCount -= 2
-    cy.getByTestid('page-comment-button').contains(commentCount);
-    cy.screenshot(`${ssPrefix}3-delete-comments`);
-  });
+  //   // Check update comment count
+  //   commentCount -= 2
+  //   cy.getByTestid('page-comment-button').contains(commentCount);
+  //   cy.screenshot(`${ssPrefix}3-delete-comments`);
+  // });
 
-  // Mention username in comment
-  it('Successfully mention username in comment', () => {
-    const username = '@adm';
 
-    cy.getByTestid('page-comment-button').click();
+  // TODO: https://redmine.weseek.co.jp/issues/139520
+  // // Mention username in comment
+  // it('Successfully mention username in comment', () => {
+  //   const username = '@adm';
 
-    // Open comment editor
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('open-comment-editor-button').click();
-      // wait until
-      return cy.get('.comment-write').then($elem => $elem.is(':visible'));
-    });
+  //   cy.getByTestid('page-comment-button').click();
 
-    cy.appendTextToEditorUntilContains(username);
+  //   // Open comment editor
+  //   cy.waitUntil(() => {
+  //     // do
+  //     cy.getByTestid('open-comment-editor-button').click();
+  //     // wait until
+  //     return cy.get('.comment-write').then($elem => $elem.is(':visible'));
+  //   });
 
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}4-mention-username-found`) });
-    // Click on mentioned username
-    cy.get('.CodeMirror-hints > li').first().click();
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}5-mention-username-mentioned`) });
-  });
+  //   cy.appendTextToEditorUntilContains(username);
 
-  it('Username not found when mention username in comment', () => {
-    const username = '@user';
+  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}4-mention-username-found`) });
+  //   // Click on mentioned username
+  //   cy.get('.CodeMirror-hints > li').first().click();
+  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}5-mention-username-mentioned`) });
+  // });
 
-    cy.getByTestid('page-comment-button').click();
+  // TODO: https://redmine.weseek.co.jp/issues/139520
+  // it('Username not found when mention username in comment', () => {
+  //   const username = '@user';
 
-    // Open comment editor
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('open-comment-editor-button').click();
-      // wait until
-      return cy.get('.comment-write').then($elem => $elem.is(':visible'));
-    });
+  //   cy.getByTestid('page-comment-button').click();
 
-    cy.appendTextToEditorUntilContains(username);
+  //   // Open comment editor
+  //   cy.waitUntil(() => {
+  //     // do
+  //     cy.getByTestid('open-comment-editor-button').click();
+  //     // wait until
+  //     return cy.get('.comment-write').then($elem => $elem.is(':visible'));
+  //   });
 
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}6-mention-username-not-found`) });
-    // Click on username not found hint
-    cy.get('.CodeMirror-hints > li').first().click();
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}7-mention-no-username-mentioned`) });
-  });
+  //   cy.appendTextToEditorUntilContains(username);
+
+  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}6-mention-username-not-found`) });
+  //   // Click on username not found hint
+  //   cy.get('.CodeMirror-hints > li').first().click();
+  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}7-mention-no-username-mentioned`) });
+  // });
 
 })

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

@@ -36,7 +36,7 @@
 @import 'bootstrap/scss/placeholders';
 
 // Helpers
-@import 'bootstrap/scss/helpers';
+@import './override/helpers';
 
 // Utilities
 @import 'bootstrap/scss/utilities/api';

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

@@ -0,0 +1,59 @@
+@mixin button-outline-neutral-variant-light(
+  $color,
+  $background: mix(#fff, $color, 100%),
+  $border: mix(#fff, $color, 70%),
+  $hover-background: mix(#fff, $color, 95%),
+  $hover-border: $border,
+  $hover-color: $color,
+  $active-background: mix(#fff, $color, 85%),
+  $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-neutral-variant-dark(
+  $color,
+  $background: mix($gray-900, $color, 100%),
+  $border: mix($gray-900, $color, 70%),
+  $hover-background: mix($gray-900, $color, 95%),
+  $hover-border: $border,
+  $hover-color: $color,
+  $active-background: mix($gray-900, $color, 85%),
+  $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};
+}

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

@@ -1,4 +1,5 @@
 @import '../mixins/button-outline-variant';
+@import '../mixins/button-outline-neutral-variant';
 
 :root[data-bs-theme='light'] {
   @each $color, $value in $theme-colors {
@@ -19,3 +20,16 @@
     }
   }
 }
+
+// == .btn-outline-neutral-secondary
+:root[data-bs-theme='light'] {
+  .btn-outline-neutral-secondary {
+    @include button-outline-neutral-variant-light($secondary);
+  }
+}
+
+:root[data-bs-theme='dark'] {
+  .btn-outline-neutral-secondary {
+    @include button-outline-neutral-variant-dark($secondary);
+  }
+}

+ 12 - 0
packages/core/scss/bootstrap/override/_helpers.scss

@@ -0,0 +1,12 @@
+@import 'bootstrap/scss/helpers/clearfix';
+@import './helpers/color-bg';
+@import 'bootstrap/scss/helpers/colored-links';
+@import 'bootstrap/scss/helpers/focus-ring';
+@import 'bootstrap/scss/helpers/icon-link';
+@import 'bootstrap/scss/helpers/ratio';
+@import 'bootstrap/scss/helpers/position';
+@import 'bootstrap/scss/helpers/stacks';
+@import 'bootstrap/scss/helpers/visually-hidden';
+@import 'bootstrap/scss/helpers/stretched-link';
+@import 'bootstrap/scss/helpers/text-truncation';
+@import 'bootstrap/scss/helpers/vr';

+ 8 - 0
packages/core/scss/bootstrap/override/helpers/_color-bg.scss

@@ -0,0 +1,8 @@
+// All-caps `RGBA()` function used because of this Sass bug: https://github.com/sass/node-sass/issues/2251
+@each $color, $value in $theme-colors {
+  .text-bg-#{$color} {
+    color: var(--#{$prefix}#{$color}) if($enable-important-utilities, !important, null);
+    background-color: var(--#{$prefix}#{$color}-bg-subtle) if($enable-important-utilities, !important, null);
+    border: 1px solid var(--#{$prefix}#{$color}-border-subtle) if($enable-important-utilities, !important, null);
+  }
+}

+ 9 - 9
packages/core/scss/bootstrap/theming/_variables.scss

@@ -5,15 +5,15 @@
 
 // Color system
 
-$gray-100: #f8f9fa !default;
-$gray-200: #e9ecef !default;
-$gray-300: #dee2e6 !default;
-$gray-400: #ced4da !default;
-$gray-500: #adb5bd !default;
-$gray-600: #6c757d !default;
-$gray-700: #495057 !default;
-$gray-800: #343a40 !default;
-$gray-900: #212529 !default;
+$gray-100: #faf9f8 !default;
+$gray-200: #efeeed !default;
+$gray-300: #e6e5e3 !default;
+$gray-400: #d8d7d5 !default;
+$gray-500: #b2b0ae !default;
+$gray-600: #767371 !default;
+$gray-700: #4d4a48 !default;
+$gray-800: #403c39 !default;
+$gray-900: #26231e !default;
 
 // The contrast ratio to reach against white, to determine if color changes from "light" to "dark". Acceptable values for WCAG 2.0 are 3, 4.5 and 7.
 // See https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast

+ 1 - 1
packages/core/scss/bootstrap/utilities.scss

@@ -1,4 +1,4 @@
 @import 'init';
 
-@import 'bootstrap/scss/helpers';
+@import './override/helpers';
 @import 'bootstrap/scss/utilities/api';

+ 6 - 0
packages/editor/package.json

@@ -32,8 +32,14 @@
     "@replit/codemirror-vscode-keymap": "^6.0.2",
     "@types/react": "^18.2.14",
     "@types/react-dom": "^18.2.6",
+    "@uiw/codemirror-theme-eclipse": "^4.21.21",
+    "@uiw/codemirror-theme-kimbie": "^4.21.21",
+    "@uiw/codemirror-themes": "^4.21.21",
     "@uiw/react-codemirror": "^4.21.8",
     "bootstrap": "^5.3.1",
+    "cm6-theme-basic-light": "^0.2.0",
+    "cm6-theme-material-dark": "^0.2.0",
+    "cm6-theme-nord": "^0.2.0",
     "codemirror": "^6.0.1",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-react-refresh": "^0.4.1",

+ 21 - 1
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -3,11 +3,12 @@ import {
 } from 'react';
 
 import { indentUnit } from '@codemirror/language';
+import { Prec } from '@codemirror/state';
 import { EditorView } from '@codemirror/view';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
 import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../consts';
-import { useFileDropzone, FileDropzoneOverlay } from '../../services';
+import { useFileDropzone, FileDropzoneOverlay, AllEditorTheme } from '../../services';
 import {
   getStrFromBol, adjustPasteData,
 } from '../../services/list-util/markdown-list-util';
@@ -31,6 +32,7 @@ type Props = {
   onUpload?: (files: File[]) => void,
   onScroll?: () => void,
   indentSize?: number,
+  editorTheme?: string,
 }
 
 export const CodeMirrorEditor = (props: Props): JSX.Element => {
@@ -41,6 +43,7 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     onUpload,
     onScroll,
     indentSize,
+    editorTheme,
   } = props;
 
   const containerRef = useRef(null);
@@ -136,6 +139,23 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   }, [onScroll, codeMirrorEditor]);
 
+  useEffect(() => {
+    if (editorTheme == null) {
+      return;
+    }
+    if (AllEditorTheme[editorTheme] == null) {
+      return;
+    }
+
+    const extension = AllEditorTheme[editorTheme];
+
+    // React CodeMirror has default theme which is default prec
+    // and extension have to be higher prec here than default theme.
+    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.high(extension));
+    return cleanupFunction;
+
+  }, [codeMirrorEditor, editorTheme]);
+
   const {
     getRootProps,
     isDragActive,

+ 3 - 1
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -24,11 +24,12 @@ type Props = {
   onScroll?: () => void,
   acceptedFileType?: AcceptedUploadFileType,
   indentSize?: number,
+  editorTheme?: string,
 }
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
-    onSave, onChange, onUpload, onScroll, acceptedFileType, indentSize,
+    onSave, onChange, onUpload, onScroll, acceptedFileType, indentSize, editorTheme,
   } = props;
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
@@ -73,6 +74,7 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
       onScroll={onScroll}
       acceptedFileType={acceptedFileTypeNoOpt}
       indentSize={indentSize}
+      editorTheme={editorTheme}
     />
   );
 };

+ 1 - 0
packages/editor/src/components/index.ts

@@ -1,2 +1,3 @@
 export * from './CodeMirrorEditor';
 export * from './CodeMirrorEditorMain';
+export * from './CodeMirrorEditorComment';

+ 3 - 1
packages/editor/src/components/playground/Playground.tsx

@@ -14,6 +14,7 @@ import { Preview } from './Preview';
 export const Playground = (): JSX.Element => {
 
   const [markdownToPreview, setMarkdownToPreview] = useState('');
+  const [editorTheme, setEditorTheme] = useState('');
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
@@ -61,11 +62,12 @@ export const Playground = (): JSX.Element => {
             onUpload={uploadHandler}
             indentSize={4}
             acceptedFileType={AcceptedUploadFileType.ALL}
+            editorTheme={editorTheme}
           />
         </div>
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">
           <Preview markdown={markdownToPreview} />
-          <PlaygroundController />
+          <PlaygroundController setEditorTheme={setEditorTheme} />
         </div>
       </div>
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '50px' }}>

+ 49 - 1
packages/editor/src/components/playground/PlaygroundController.tsx

@@ -3,6 +3,9 @@ import { useCallback } from 'react';
 import { useForm } from 'react-hook-form';
 
 import { GlobalCodeMirrorEditorKey } from '../../consts';
+import {
+  AllEditorTheme,
+} from '../../services';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
 export const InitEditorValueRow = (): JSX.Element => {
@@ -68,11 +71,56 @@ export const SetCaretLineRow = (): JSX.Element => {
   );
 };
 
-export const PlaygroundController = (): JSX.Element => {
+type SetThemeRowProps = {
+  setEditorTheme: (value: string) => void,
+}
+const SetThemeRow = (props: SetThemeRowProps): JSX.Element => {
+
+  const { setEditorTheme } = props;
+
+  const createItems = (items: string[]): JSX.Element => {
+    return (
+      <div>
+        { items.map((theme) => {
+          return (
+            <button
+              type="button"
+              className="btn btn-outline-secondary"
+              onClick={() => {
+                setEditorTheme(theme);
+              }}
+            >{theme}
+            </button>
+          );
+        }) }
+      </div>
+    );
+  };
+
+  return (
+    <>
+      <div className="row mt-3">
+        <h2>default</h2>
+        <div className="col">
+          {createItems(Object.keys(AllEditorTheme))}
+        </div>
+      </div>
+    </>
+  );
+};
+
+
+type PlaygroundControllerProps = {
+  setEditorTheme: (value: string) => void
+};
+
+export const PlaygroundController = (props: PlaygroundControllerProps): JSX.Element => {
+  const { setEditorTheme } = props;
   return (
     <div className="container mt-5">
       <InitEditorValueRow />
       <SetCaretLineRow />
+      <SetThemeRow setEditorTheme={setEditorTheme} />
     </div>
   );
 };

+ 80 - 0
packages/editor/src/services/editor-theme/ayu.ts

@@ -0,0 +1,80 @@
+// Ref: https://github.com/vadimdemedes/thememirror/blob/94a6475a9113ec03d880fcb817aadcc5a16e82e4/source/themes/ayu-light.ts
+
+import { tags as t } from '@lezer/highlight';
+import { createTheme } from '@uiw/codemirror-themes';
+
+// Author: Konstantin Pschera
+export const ayu = createTheme({
+  theme: 'light',
+  settings: {
+    background: '#fcfcfc',
+    foreground: '#5c6166',
+    caret: '#ffaa33',
+    selection: '#036dd626',
+    gutterBackground: '#fcfcfc',
+    gutterForeground: '#8a919966',
+    lineHighlight: '#8a91991a',
+  },
+  styles: [
+    {
+      tag: t.comment,
+      color: '#787b8099',
+    },
+    {
+      tag: t.string,
+      color: '#86b300',
+    },
+    {
+      tag: t.regexp,
+      color: '#4cbf99',
+    },
+    {
+      tag: [t.number, t.bool, t.null],
+      color: '#ffaa33',
+    },
+    {
+      tag: t.variableName,
+      color: '#5c6166',
+    },
+    {
+      tag: [t.definitionKeyword, t.modifier],
+      color: '#fa8d3e',
+    },
+    {
+      tag: [t.keyword, t.special(t.brace)],
+      color: '#fa8d3e',
+    },
+    {
+      tag: t.operator,
+      color: '#ed9366',
+    },
+    {
+      tag: t.separator,
+      color: '#5c6166b3',
+    },
+    {
+      tag: t.punctuation,
+      color: '#5c6166',
+    },
+    {
+      tag: [t.definition(t.propertyName), t.function(t.variableName)],
+      color: '#f2ae49',
+    },
+    {
+      tag: [t.className, t.definition(t.typeName)],
+      color: '#22a4e6',
+    },
+    {
+      tag: [t.tagName, t.typeName, t.self, t.labelName],
+      color: '#55b4d4',
+    },
+    {
+      tag: t.angleBracket,
+      color: '#55b4d480',
+    },
+    {
+      tag: t.attributeName,
+      color: '#f2ae49',
+    },
+  ],
+});

+ 86 - 0
packages/editor/src/services/editor-theme/cobalt.ts

@@ -0,0 +1,86 @@
+// Ref: https://github.com/vadimdemedes/thememirror/blob/94a6475a9113ec03d880fcb817aadcc5a16e82e4/source/themes/cobalt.ts
+
+import { tags as t } from '@lezer/highlight';
+import { createTheme } from '@uiw/codemirror-themes';
+
+// Author: Jacob Rus
+export const cobalt = createTheme({
+  theme: 'dark',
+  settings: {
+    background: '#00254b',
+    foreground: '#FFFFFF',
+    caret: '#FFFFFF',
+    selection: '#B36539BF',
+    gutterBackground: '#00254b',
+    gutterForeground: '#FFFFFF70',
+    lineHighlight: '#00000059',
+  },
+  styles: [
+    {
+      tag: t.comment,
+      color: '#0088FF',
+    },
+    {
+      tag: t.string,
+      color: '#3AD900',
+    },
+    {
+      tag: t.regexp,
+      color: '#80FFC2',
+    },
+    {
+      tag: [t.number, t.bool, t.null],
+      color: '#FF628C',
+    },
+    {
+      tag: [t.definitionKeyword, t.modifier],
+      color: '#FFEE80',
+    },
+    {
+      tag: t.variableName,
+      color: '#CCCCCC',
+    },
+    {
+      tag: t.self,
+      color: '#FF80E1',
+    },
+    {
+      tag: [
+        t.className,
+        t.definition(t.propertyName),
+        t.function(t.variableName),
+        t.definition(t.typeName),
+        t.labelName,
+      ],
+      color: '#FFDD00',
+    },
+    {
+      tag: [t.keyword, t.operator],
+      color: '#FF9D00',
+    },
+    {
+      tag: [t.propertyName, t.typeName],
+      color: '#80FFBB',
+    },
+    {
+      tag: t.special(t.brace),
+      color: '#EDEF7D',
+    },
+    {
+      tag: t.attributeName,
+      color: '#9EFFFF',
+    },
+    {
+      tag: t.derefOperator,
+      color: '#fff',
+    },
+    {
+      tag: [t.url, t.escape],
+      color: '#497DBA',
+    },
+    {
+      tag: [t.brace, t.processingInstruction, t.inserted],
+      color: '#7491B4',
+    },
+  ],
+});

+ 26 - 0
packages/editor/src/services/editor-theme/index.ts

@@ -0,0 +1,26 @@
+import { Extension } from '@codemirror/state';
+import { eclipse } from '@uiw/codemirror-theme-eclipse';
+import { kimbie } from '@uiw/codemirror-theme-kimbie';
+import { basicLight } from 'cm6-theme-basic-light';
+import { materialDark as materialDarkCM6 } from 'cm6-theme-material-dark';
+import { nord as nordCM6 } from 'cm6-theme-nord';
+
+import { ayu } from './ayu';
+import { cobalt } from './cobalt';
+import { originalDark } from './original-dark';
+import { originalLight } from './original-light';
+import { rosePine } from './rose-pine';
+
+
+export const AllEditorTheme: Record<string, Extension> = {
+  DefaultLight: originalLight,
+  Eclipse: eclipse,
+  Basic: basicLight,
+  Ayu: ayu,
+  'Rosé Pine': rosePine,
+  DefaultDark: originalDark,
+  Material: materialDarkCM6,
+  Nord: nordCM6,
+  Cobalt: cobalt,
+  Kimbie: kimbie,
+};

+ 32 - 0
packages/editor/src/services/editor-theme/original-dark.ts

@@ -0,0 +1,32 @@
+// Ref: https://github.com/uiwjs/react-codemirror/blob/bf3b862923d0cb04ccf4bb9da0791bdc7fd6d29b/themes/sublime/src/index.ts
+
+import { tags as t } from '@lezer/highlight';
+import { createTheme } from '@uiw/codemirror-themes';
+
+export const originalDark = createTheme({
+  theme: 'dark',
+  settings: {
+    background: '#303841',
+    foreground: '#FFFFFF',
+    caret: '#FBAC52',
+    selection: '#4C5964',
+    selectionMatch: '#3A546E',
+    gutterBackground: '#303841',
+    gutterForeground: '#FFFFFF70',
+    lineHighlight: '#00000059',
+  },
+  styles: [
+    { tag: [t.meta, t.comment], color: '#A2A9B5' },
+    { tag: [t.attributeName, t.keyword], color: '#B78FBA' },
+    { tag: t.function(t.variableName), color: '#5AB0B0' },
+    { tag: [t.string, t.regexp, t.attributeValue], color: '#99C592' },
+    { tag: t.operator, color: '#f47954' },
+    // { tag: t.moduleKeyword, color: 'red' },
+    { tag: [t.tagName, t.modifier], color: '#E35F63' },
+    { tag: [t.number, t.definition(t.tagName), t.className, t.definition(t.variableName)], color: '#fbac52' },
+    { tag: [t.atom, t.bool, t.special(t.variableName)], color: '#E35F63' },
+    { tag: t.variableName, color: '#539ac4' },
+    { tag: [t.propertyName, t.typeName], color: '#629ccd' },
+    { tag: t.propertyName, color: '#36b7b5' },
+  ],
+});

+ 35 - 0
packages/editor/src/services/editor-theme/original-light.ts

@@ -0,0 +1,35 @@
+// Ref: https://github.com/uiwjs/react-codemirror/blob/bf3b862923d0cb04ccf4bb9da0791bdc7fd6d29b/themes/github/src/index.ts
+
+import { Extension } from '@codemirror/state';
+import { tags as t } from '@lezer/highlight';
+import { createTheme } from '@uiw/codemirror-themes';
+
+
+export const originalLight: Extension = createTheme({
+  theme: 'light',
+  settings: {
+    background: '#fff',
+    foreground: '#24292e',
+    selection: '#BBDFFF',
+    selectionMatch: '#BBDFFF',
+    gutterBackground: '#fff',
+    gutterForeground: '#6e7781',
+  },
+  styles: [
+    { tag: [t.standard(t.tagName), t.tagName], color: '#116329' },
+    { tag: [t.comment, t.bracket], color: '#6a737d' },
+    { tag: [t.className, t.propertyName], color: '#6f42c1' },
+    { tag: [t.variableName, t.attributeName, t.number, t.operator], color: '#005cc5' },
+    { tag: [t.keyword, t.typeName, t.typeOperator, t.typeName], color: '#d73a49' },
+    { tag: [t.string, t.meta, t.regexp], color: '#032f62' },
+    { tag: [t.name, t.quote], color: '#22863a' },
+    { tag: [t.heading, t.strong], color: '#24292e', fontWeight: 'bold' },
+    { tag: [t.emphasis], color: '#24292e', fontStyle: 'italic' },
+    { tag: [t.deleted], color: '#b31d28', backgroundColor: 'ffeef0' },
+    { tag: [t.atom, t.bool, t.special(t.variableName)], color: '#e36209' },
+    { tag: [t.url, t.escape, t.regexp, t.link], color: '#032f62' },
+    { tag: t.link, textDecoration: 'underline' },
+    { tag: t.strikethrough, textDecoration: 'line-through' },
+    { tag: t.invalid, color: '#cb2431' },
+  ],
+});

+ 60 - 0
packages/editor/src/services/editor-theme/rose-pine.ts

@@ -0,0 +1,60 @@
+// Ref: https://github.com/vadimdemedes/thememirror/blob/94a6475a9113ec03d880fcb817aadcc5a16e82e4/source/themes/rose-pine-dawn.ts
+
+import { tags as t } from '@lezer/highlight';
+import { createTheme } from '@uiw/codemirror-themes';
+
+// Author: Rosé Pine
+export const rosePine = createTheme({
+  theme: 'light',
+  settings: {
+    background: '#faf4ed',
+    foreground: '#575279',
+    caret: '#575279',
+    selection: '#6e6a8614',
+    gutterBackground: '#faf4ed',
+    gutterForeground: '#57527970',
+    lineHighlight: '#6e6a860d',
+  },
+  styles: [
+    {
+      tag: t.comment,
+      color: '#9893a5',
+    },
+    {
+      tag: [t.bool, t.null],
+      color: '#286983',
+    },
+    {
+      tag: t.number,
+      color: '#d7827e',
+    },
+    {
+      tag: t.className,
+      color: '#d7827e',
+    },
+    {
+      tag: [t.angleBracket, t.tagName, t.typeName],
+      color: '#56949f',
+    },
+    {
+      tag: t.attributeName,
+      color: '#907aa9',
+    },
+    {
+      tag: t.punctuation,
+      color: '#797593',
+    },
+    {
+      tag: [t.keyword, t.modifier],
+      color: '#286983',
+    },
+    {
+      tag: [t.string, t.regexp],
+      color: '#ea9d34',
+    },
+    {
+      tag: t.variableName,
+      color: '#d7827e',
+    },
+  ],
+});

+ 1 - 0
packages/editor/src/services/index.ts

@@ -1,2 +1,3 @@
 export * from './codemirror-editor';
 export * from './file-dropzone';
+export * from './editor-theme';

+ 3 - 0
packages/preset-themes/src/consts/preset-themes.ts

@@ -18,6 +18,7 @@ export const PresetThemes = {
   NATURE: 'nature',
   SPRING: 'spring',
   WOOD: 'wood',
+  CLASSIC: 'classic',
 } as const;
 export type PresetThemes = typeof PresetThemes[keyof typeof PresetThemes];
 
@@ -45,6 +46,8 @@ export const PresetThemesMetadatas: GrowiThemeMetadata[] = [
     name: PresetThemes.FIRE_RED,      schemeType: BOTH, bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', accent: '#EA5532',
   }, {
     name: PresetThemes.JADE_GREEN,    schemeType: BOTH, bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', accent: '#38B48B',
+  }, {
+    name: PresetThemes.CLASSIC,    schemeType: BOTH, bg: '#FDFDFD', topbar: '#E1E9F4', sidebar: '#E1E9F4', accent: '#439FD8',
   },
   // light only
   {

+ 67 - 0
packages/preset-themes/src/styles/classic.scss

@@ -0,0 +1,67 @@
+:root[data-bs-theme='light'] {
+  @import '@growi/core/scss/bootstrap/init-stage-1';
+  @import '@growi/core/scss/bootstrap/theming/variables';
+  @import '@growi/core/scss/bootstrap/theming/utils/color-palette';
+
+  $primary: #3491CB;
+  $highlight: #B4CAE5;
+
+  @include generate-color-palette('primary', $primary, black, white);
+  @include generate-color-palette('highlight', $highlight,black, white);
+
+  $body-color:                #112744;
+  $body-bg:                   white;
+
+  $body-secondary-color:      rgba($body-color, .75);
+  $body-secondary-bg:         $gray-200;
+
+  $body-tertiary-color:       rgba($body-color, .5);
+  $body-tertiary-bg:          $gray-100;
+
+  $border-color:              var(--grw-highlight-200);
+
+  $link-color:                rgba(25, 56, 186, 1);
+
+  @import 'bootstrap/scss/variables';
+  @import 'bootstrap/scss/variables-dark';
+
+  @import '@growi/core/scss/bootstrap/init-stage-2';
+
+  @import '@growi/core/scss/bootstrap/theming/apply-light';
+
+  --grw-sidebar-nav-btn-color: var(--grw-highlight-600);
+}
+
+:root[data-bs-theme='dark'] {
+  @import '@growi/core/scss/bootstrap/init-stage-1';
+  @import '@growi/core/scss/bootstrap/theming/variables';
+  @import '@growi/core/scss/bootstrap/theming/utils/color-palette';
+
+  $primary: #DB17C2;
+  $highlight: #68829D;
+
+  @include generate-color-palette('primary', $primary, black, white);
+  @include generate-color-palette('highlight', $highlight, black, white);
+
+  $body-color-dark:                   $gray-300;
+  $body-bg-dark:                      #1c1a1a;
+
+  $body-secondary-color-dark:         rgba($body-color-dark, .75);
+  $body-secondary-bg-dark:            $gray-800;
+
+  $body-tertiary-color-dark:          rgba($body-color-dark, .5);
+  $body-tertiary-bg-dark:             mix($gray-800, $gray-900, 50%);
+
+  $border-color-dark:                 var(--grw-highlight-200);
+
+  $link-color-dark:                   mix(#68829D, white, 80%);
+
+  @import 'bootstrap/scss/variables';
+  @import 'bootstrap/scss/variables-dark';
+
+  @import '@growi/core/scss/bootstrap/init-stage-2';
+
+  @import '@growi/core/scss/bootstrap/theming/apply-dark';
+
+  --grw-sidebar-nav-btn-color: rgba(var(--grw-highlight-400-rgb), 0.8);
+}

+ 1 - 0
packages/preset-themes/vite.themes.config.ts

@@ -25,6 +25,7 @@ export default defineConfig(({ mode }) => {
           '/src/styles/nature.scss',
           '/src/styles/spring.scss',
           '/src/styles/wood.scss',
+          '/src/styles/classic.scss',
         ],
         output: {
           assetFileNames: isProd

+ 38 - 0
yarn.lock

@@ -4473,6 +4473,29 @@
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.0.0"
 
+"@uiw/codemirror-theme-eclipse@^4.21.21":
+  version "4.21.21"
+  resolved "https://registry.yarnpkg.com/@uiw/codemirror-theme-eclipse/-/codemirror-theme-eclipse-4.21.21.tgz#d38cf20ce903b7aecefb9dbe1751a240590f154f"
+  integrity sha512-Dp5j4mFPH8UOoH37b2Wc45khNGcyusCDbfRw0jeBAGW258xH4UbHBlEIY+1/z4bloIfoguCyE3nPQnsa/M59Qg==
+  dependencies:
+    "@uiw/codemirror-themes" "4.21.21"
+
+"@uiw/codemirror-theme-kimbie@^4.21.21":
+  version "4.21.21"
+  resolved "https://registry.yarnpkg.com/@uiw/codemirror-theme-kimbie/-/codemirror-theme-kimbie-4.21.21.tgz#dbdfc23c3957d55015ab5b0463526abffe73d816"
+  integrity sha512-dhWqIz1nsFzqoe5U3jIPeCJ9/c534YMmsGvNq3JJgRjD/KZeV8TSOJfuJNxI6jCskXh149Z5wghKE+FnNp/eUA==
+  dependencies:
+    "@uiw/codemirror-themes" "4.21.21"
+
+"@uiw/codemirror-themes@4.21.21", "@uiw/codemirror-themes@^4.21.21":
+  version "4.21.21"
+  resolved "https://registry.yarnpkg.com/@uiw/codemirror-themes/-/codemirror-themes-4.21.21.tgz#26efb06ecce9a51aa73d39311c90f8fcb06fdc43"
+  integrity sha512-ljVcMGdaxo75UaH+EqxJ+jLyMVVgeSfW2AKyT1VeLy+4SDpuqNQ7wq5XVxktsG6LH+OvgSFndWXgPANf4+gQcA==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+
 "@uiw/react-codemirror@^4.21.8":
   version "4.21.8"
   resolved "https://registry.yarnpkg.com/@uiw/react-codemirror/-/react-codemirror-4.21.8.tgz#0b2d833a0c7256c23f83b342463276c762863bad"
@@ -6096,6 +6119,21 @@ clsx@^1.0.4, clsx@^1.1.1:
   resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
   integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
 
+cm6-theme-basic-light@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/cm6-theme-basic-light/-/cm6-theme-basic-light-0.2.0.tgz#29d2d6b9675feb7b563b31eda6f3da37d9ae3167"
+  integrity sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA==
+
+cm6-theme-material-dark@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/cm6-theme-material-dark/-/cm6-theme-material-dark-0.2.0.tgz#c733243a8a31da5d953fa551b2548f358aa37a64"
+  integrity sha512-H09JZihzg4w0mTtOqo5bQdxItkQWw+ergKlk7BSfwYjaR2nOi+wIN0R+ByAo7bON8GbFODvjTxH3EIqdhovFeA==
+
+cm6-theme-nord@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/cm6-theme-nord/-/cm6-theme-nord-0.2.0.tgz#2a00c47cdf6119b8248dbed8d9b572841bf321a7"
+  integrity sha512-jTh+5nvl+N/5CtTK7UVcrxDCj2AOStvbNM8uP6tx6amq4QaaLDlapjMw+MNzEkvxcPnHY+YM91tbklS2KNlR2w==
+
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"