Browse Source

Merge pull request #10847 from growilabs/feat/160341-editor-guide

feat: Editor guide
mergify[bot] 1 week ago
parent
commit
93f2898bbc
22 changed files with 1531 additions and 31 deletions
  1. 75 0
      apps/app/public/static/locales/en_US/translation.json
  2. 76 0
      apps/app/public/static/locales/fr_FR/translation.json
  3. 76 0
      apps/app/public/static/locales/ja_JP/translation.json
  4. 75 0
      apps/app/public/static/locales/ko_KR/translation.json
  5. 76 0
      apps/app/public/static/locales/zh_CN/translation.json
  6. 50 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/EditorGuideModal.module.scss
  7. 143 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/EditorGuideModal.tsx
  8. 21 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/components/GuideRow.module.scss
  9. 89 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/components/GuideRow.tsx
  10. 19 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/DecorationTab.module.scss
  11. 214 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/DecorationTab.tsx
  12. 18 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/LayoutTab.module.scss
  13. 230 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/LayoutTab.tsx
  14. 20 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/TextStyleTab.module.scss
  15. 219 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/TextStyleTab.tsx
  16. 29 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/dynamic.tsx
  17. 1 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/index.ts
  18. 35 31
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  19. 4 0
      packages/custom-icons/svg/editor_guide.svg
  20. 31 0
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/EditorGuideButton.tsx
  21. 2 0
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/Toolbar.tsx
  22. 28 0
      packages/editor/src/states/modal/editor-guide.ts

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

@@ -1093,6 +1093,7 @@
     "checklist": "Checklist",
     "code": "Code",
     "diagram": "Diagram",
+    "editor_guide": "Editor Guide",
     "emoji": "Emoji",
     "heading": "Heading",
     "italic": "Italic",
@@ -1102,5 +1103,79 @@
     "table": "Table",
     "template": "Template",
     "text_formatting": "Text Formatting"
+  },
+  "editor_guide": {
+    "title": "Editor Guide",
+    "tabs": {
+      "textstyle": "Text Style",
+      "layout": "Layout",
+      "decoration": "Decoration"
+    },
+    "textstyle": {
+      "copy_done": "Copied!",
+      "this": "This is",
+      "is": "",
+      "bold": "bold",
+      "italic": "italic",
+      "strikethrough": "strikethrough",
+      "inline_code": "inline code",
+      "bold_italic": "Bold or italic all",
+      "emoji": "Emoji",
+      "sub_sup": "Subscript / Superscript",
+      "link_label": "Link with label",
+      "link_docs": "GROWI Documentation",
+      "link_growi": "GROWI Link",
+      "link_sandbox": "Sandbox page is here",
+      "all_important": "This text is all\nimportant",
+      "sub_text": "subscript",
+      "sup_text": "superscript",
+      "is_text": "This is {{val}}"
+    },
+    "layout": {
+      "copy_done": "Copied!",
+      "header": "Headers",
+      "header_text": "Heading",
+      "list": "Unordered List",
+      "list_text": "This is a list item",
+      "ordered_list": "Ordered List",
+      "ordered_list_text": "This is a numbered item",
+      "checkbox": "Checkboxes",
+      "task": "Task ",
+      "quote": "Blockquotes",
+      "quote_text": "Quote",
+      "multi_quote": "Nested Quote",
+      "hr": "Horizontal Rule",
+      "br": "Line Break",
+      "br_code": "Add two spaces  to break\nthe line",
+      "br_preview_1": "Add two spaces to",
+      "br_preview_2": "break the line",
+      "code_block": "Code Blocks",
+      "code_block_text": "Insert code here",
+      "table": "Tables",
+      "left": "Align Left",
+      "right": "Align Right",
+      "center": "Align Center",
+      "row_text": "This column is",
+      "row_display": "aligned",
+      "footnote": "Footnotes",
+      "footnote_label": "Footnote text",
+      "footnote_desc": "Write the note content like this"
+    },
+    "decoration": {
+      "copy_done": "Copied to clipboard!",
+      "style": "Style",
+      "alert": "Alert",
+      "alert_with_custom_title": "Alert with label",
+      "alert_with_custom_title_text": "Custom Title",
+      "alert_unavailable": "Unavailable in this style",
+      "badge": "Badge",
+      "text_color": "Text Color",
+      "back_color": "Background Color",
+      "placeholder": "Sample text goes here",
+      "docs_title": "Bootstrap 5 Official Documentation",
+      "docs_badge": "Learn more about Badges",
+      "docs_color": "Learn more about Colors",
+      "docs_alert": "Learn more about Alert Blocks"
+    }
   }
 }

+ 76 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -1085,6 +1085,7 @@
     "checklist": "Liste de contrôle",
     "code": "Code",
     "diagram": "Diagramme",
+    "editor_guide": "Guide de l'Éditeur",
     "emoji": "Emoji",
     "heading": "Titre",
     "italic": "Italique",
@@ -1094,5 +1095,80 @@
     "table": "Tableau",
     "template": "Modèle",
     "text_formatting": "Mise en forme du texte"
+  },
+  "editor_guide": {
+    "title": "Guide de l'Éditeur",
+    "tabs": {
+      "textstyle": "Style de Texte",
+      "layout": "Mise en Page",
+      "decoration": "Décoration"
+    },
+    "textstyle": {
+      "copy_done": "Copié !",
+      "this": "Ceci est du",
+      "is": "",
+      "bold": "gras",
+      "italic": "italique",
+      "strikethrough": "barré",
+      "inline_code": "code en ligne",
+      "bold_italic": "Tout en gras ou italique",
+      "emoji": "Émoticône",
+      "sub_sup": "Indice / Exposant",
+      "link_label": "Lien avec étiquette",
+      "link_docs": "Documentation GROWI",
+      "link_growi": "Lien GROWI",
+      "link_sandbox": "La page bac à sable est ici",
+      "all_important": "Tout ce texte est\nimportant",
+      "sub_text": "indice",
+      "sup_text": "exposant",
+      "is_text": "Ceci est du {{val}}"
+    },
+    "layout": {
+      "copy_done": "Copié !",
+      "header": "En-têtes",
+      "header_text": "Titre",
+      "list": "Liste à puces",
+      "list_text": "Ceci est un élément de liste",
+      "ordered_list": "Liste ordonnée",
+      "ordered_list_text": "Ceci est un élément numéroté",
+      "checkbox": "Cases à cocher",
+      "task": "Tâche ",
+      "quote": "Citations",
+      "quote_text": "Citation",
+      "multi_quote": "Citation imbriquée",
+      "hr": "Ligne horizontale",
+      "br": "Saut de ligne",
+      "br_code": "Ajoutez deux espaces  pour\nrompre la ligne",
+      "br_preview_1": "Ajoutez deux espaces pour",
+      "br_preview_2": "rompre la ligne",
+      "code_block": "Blocs de code",
+      "code_block_text": "Insérez le code ici",
+      "table": "Tableaux",
+      "left": "Aligner à gauche",
+      "right": "Aligner à droite",
+      "center": "Aligner au centre",
+      "row_text": "Cette colonne est",
+      "row_display": "alignée",
+      "footnote": "Notes de bas de page",
+      "footnote_label": "Texte avec note",
+      "footnote_desc": "Écrivez le contenu de la note comme ceci"
+    },
+    "decoration": {
+      "copy_done": "Copié dans le presse-papiers !",
+      "style": "Style",
+      "alert": "Alerte",
+      "alert_with_custom_title": "Alerte avec étiquette",
+      "alert_with_custom_title_text": "Titre personnalisé",
+      "alert_unavailable": "Non disponible pour ce style",
+      "badge": "Badge",
+      "text_color": "Couleur du texte",
+      "back_color": "Couleur d'arrière-plan",
+
+      "placeholder": "Le texte s'affiche ici",
+      "docs_title": "Documentation officielle de Bootstrap 5",
+      "docs_badge": "En savoir plus sur les Badges",
+      "docs_color": "En savoir plus sur les Couleurs",
+      "docs_alert": "En savoir plus sur les Blocs d'alerte"
+    }
   }
 }

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

@@ -1126,6 +1126,7 @@
     "checklist": "チェックリスト",
     "code": "コード",
     "diagram": "ダイアグラム",
+    "editor_guide": "エディターガイド",
     "emoji": "絵文字",
     "heading": "見出し",
     "italic": "イタリック",
@@ -1135,5 +1136,80 @@
     "table": "テーブル",
     "template": "テンプレート",
     "text_formatting": "テキスト書式"
+  },
+  "editor_guide": {
+    "title": "エディターガイド",
+    "tabs": {
+      "textstyle": "テキストスタイル",
+      "layout": "レイアウト",
+      "decoration": "装飾"
+    },
+    "textstyle": {
+      "copy_done": "コピーしました!",
+      "this": "これは",
+      "is": "です",
+      "bold": "太字",
+      "italic": "斜体",
+      "strikethrough": "取り消し線",
+      "inline_code": "インラインコード",
+      "bold_italic": "全体が太字か斜体",
+      "emoji": "絵文字",
+      "sub_sup": "下付き・上付き",
+      "link_label": "ラベル付きリンク",
+      "link_docs": "GROWI ドキュメント",
+      "link_growi": "GROWIのリンク",
+      "link_sandbox": "砂場ページはこちら",
+      "all_important": "このテキストはすべて\n重要です",
+      "sub_text": "下付き",
+      "sup_text": "上付き",
+      "is_text": "これは{{val}}です"
+    },
+    "layout": {
+      "copy_done": "コピーしました!",
+      "header": "ヘッダー",
+      "header_text": "見出し",
+      "list": "箇条書きリスト",
+      "list_text": "箇条書きリストです",
+      "ordered_list": "番号付きリスト",
+      "ordered_list_text": "番号付きリストです",
+      "checkbox": "チェックボックス",
+      "task": "タスク",
+      "quote": "引用",
+      "quote_text": "引用",
+      "multi_quote": "多重引用",
+      "hr": "水平線",
+      "br": "改行",
+      "br_code": "スペース2つ入れると  改行\nできます",
+      "br_preview_1": "スペース2つ入れると",
+      "br_preview_2": "改行できます",
+      "code_block": "コードブロック",
+      "code_block_text": "ここにコードを追加",
+      "table": "表",
+      "left": "左揃え",
+      "right": "右揃え",
+      "center": "中央揃え",
+      "row_text": "この列は",
+      "row_display": "で表示されます",
+      "footnote": "脚注",
+      "footnote_label": "脚注つきテキスト",
+      "footnote_desc": "注記はこのように書きます"
+    },
+    "decoration": {
+      "copy_done": "コピーしました!",
+      "style": "スタイル",
+      "alert": "アラート",
+      "alert_with_custom_title": "ラベル付きアラート",
+      "alert_with_custom_title_text": "カスタムタイトル",
+      "alert_unavailable": "このスタイルでは使用できません",
+      "badge": "バッジ",
+      "text_color": "テキストカラー",
+      "back_color": "背景色",
+
+      "placeholder": "テキストが入ります",
+      "docs_title": "Bootstrap5 公式ドキュメント",
+      "docs_badge": "バッジの詳細はこちら",
+      "docs_color": "カラーの詳細はこちら",
+      "docs_alert": "アラートブロックの詳細はこちら"
+    }
   }
 }

+ 75 - 0
apps/app/public/static/locales/ko_KR/translation.json

@@ -1062,5 +1062,80 @@
     "table": "표",
     "template": "템플릿",
     "text_formatting": "텍스트 서식"
+  },
+  "editor_guide": {
+    "title": "에디터 가이드",
+    "tabs": {
+      "textstyle": "텍스트 스타일",
+      "layout": "레이아웃",
+      "decoration": "데코레이션"
+    },
+    "textstyle": {
+      "copy_done": "복사되었습니다!",
+      "this": "이것은",
+      "is": "입니다",
+      "bold": "굵게",
+      "italic": "기울임꼴",
+      "strikethrough": "취소선",
+      "inline_code": "인라인 코드",
+      "bold_italic": "전체가 굵게 또는 기울임꼴",
+      "emoji": "이모지",
+      "sub_sup": "아래 첨자 / 위 첨자",
+      "link_label": "라벨이 있는 링크",
+      "link_docs": "GROWI 문서",
+      "link_growi": "GROWI 링크",
+      "link_sandbox": "연습장 페이지는 여기입니다",
+      "all_important": "이 텍스트는 모두\n중요합니다",
+      "sub_text": "아래 첨자",
+      "sup_text": "위 첨자",
+      "is_text": "이것은 {{val}}입니다"
+    },
+    "layout": {
+      "copy_done": "복사되었습니다!",
+      "header": "헤더",
+      "header_text": "제목",
+      "list": "글머리 기호 목록",
+      "list_text": "목록 항목입니다",
+      "ordered_list": "번호 매기기 목록",
+      "ordered_list_text": "번호 항목입니다",
+      "checkbox": "체크박스",
+      "task": "할 일 ",
+      "quote": "인용구",
+      "quote_text": "인용",
+      "multi_quote": "중첩 인용",
+      "hr": "가로줄",
+      "br": "줄바꿈",
+      "br_code": "스페이스를 두 번 입력하면  줄바꿈을\n할 수 있습니다",
+      "br_preview_1": "스페이스를 두 번 입력하면",
+      "br_preview_2": "줄바꿈을 할 수 있습니다",
+      "code_block": "코드 블록",
+      "code_block_text": "여기에 코드를 추가하세요",
+      "table": "표",
+      "left": "왼쪽 정렬",
+      "right": "오른쪽 정렬",
+      "center": "가운데 정렬",
+      "row_text": "이 열은",
+      "row_display": "됩니다",
+      "footnote": "각주",
+      "footnote_label": "각주가 있는 텍스트",
+      "footnote_desc": "주석은 이와 같이 작성합니다"
+    },
+    "decoration": {
+      "copy_done": "클립보드에 복사되었습니다!",
+      "style": "스타일",
+      "alert": "알림",
+      "alert_with_custom_title": "레이블이 있는 알림",
+      "alert_with_custom_title_text": "사용자 정의 제목",
+      "alert_unavailable": "이 스타일에서는 사용할 수 없습니다",
+      "badge": "배지",
+      "text_color": "텍스트 색상",
+      "back_color": "배경 색상",
+
+      "placeholder": "텍스트가 입력됩니다",
+      "docs_title": "Bootstrap 5 공식 문서",
+      "docs_badge": "배지 상세 정보",
+      "docs_color": "색상 상세 정보",
+      "docs_alert": "알림 블록 상세 정보"
+    }
   }
 }

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

@@ -1098,6 +1098,7 @@
     "checklist": "清单",
     "code": "代码",
     "diagram": "图表",
+    "editor_guide": "编辑器指南",
     "emoji": "表情符号",
     "heading": "标题",
     "italic": "斜体",
@@ -1107,5 +1108,80 @@
     "table": "表格",
     "template": "模板",
     "text_formatting": "文本格式"
+  },
+  "editor_guide": {
+    "title": "编辑器指南",
+    "tabs": {
+      "textstyle": "文本样式",
+      "layout": "页面布局",
+      "decoration": "装饰"
+    },
+    "textstyle": {
+      "copy_done": "已复制!",
+      "this": "这是",
+      "is": "效果",
+      "bold": "加粗",
+      "italic": "斜体",
+      "strikethrough": "删除线",
+      "inline_code": "行内代码",
+      "bold_italic": "全体加粗或斜体",
+      "emoji": "表情符号",
+      "sub_sup": "下标 / 上标",
+      "link_label": "带有标签的链接",
+      "link_docs": "GROWI 文档",
+      "link_growi": "GROWI 链接",
+      "link_sandbox": "沙盒页面在这里",
+      "all_important": "这段文字非常\n重要",
+      "sub_text": "下标",
+      "sup_text": "上标",
+      "is_text": "这是{{val}}效果"
+    },
+    "layout": {
+      "copy_done": "已复制!",
+      "header": "标题",
+      "header_text": "标题",
+      "list": "无序列表",
+      "list_text": "这是一个列表项",
+      "ordered_list": "有序列表",
+      "ordered_list_text": "这是一个编号项",
+      "checkbox": "复选框",
+      "task": "任务 ",
+      "quote": "引用",
+      "quote_text": "引用",
+      "multi_quote": "多重引用",
+      "hr": "分割线",
+      "br": "换行",
+      "br_code": "输入两个空格  即可\n换行",
+      "br_preview_1": "输入两个空格即可",
+      "br_preview_2": "换行",
+      "code_block": "代码块",
+      "code_block_text": "在此处插入代码",
+      "table": "表格",
+      "left": "左对齐",
+      "right": "右对齐",
+      "center": "居中对齐",
+      "row_text": "本列将",
+      "row_display": "显示",
+      "footnote": "脚注",
+      "footnote_label": "带有脚注的文本",
+      "footnote_desc": "注记的写法如下"
+    },
+    "decoration": {
+      "copy_done": "已复制到剪贴板!",
+      "style": "样式",
+      "alert": "提示",
+      "alert_with_custom_title": "带标签的提示",
+      "alert_with_custom_title_text": "自定义标题",
+      "alert_unavailable": "此样式不可用",
+      "badge": "徽章",
+      "text_color": "文本颜色",
+      "back_color": "背景颜色",
+
+      "placeholder": "此处显示文本内容",
+      "docs_title": "Bootstrap 5 官方文档",
+      "docs_badge": "了解更多关于徽章的信息",
+      "docs_color": "了解更多关于颜色的信息",
+      "docs_alert": "了解更多关于警告框的信息"
+    }
   }
 }

+ 50 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/EditorGuideModal.module.scss

@@ -0,0 +1,50 @@
+.editor-guide-modal {
+  position: fixed !important;
+  top: var(--egm-top) !important;
+  left: var(--egm-left) !important;
+  display: flex !important;
+  align-items: center;
+  justify-content: center;
+  width: var(--egm-width) !important;
+  height: var(--egm-height) !important;
+  padding: 0 !important;
+  overflow: hidden !important;
+}
+
+.editor-guide-backdrop {
+  position: fixed !important;
+  top: var(--egm-top) !important;
+  left: var(--egm-left) !important;
+  width: var(--egm-width) !important;
+  height: var(--egm-height) !important;
+}
+
+.card-body-scrollable {
+  overflow-y: auto;
+}
+
+.editor-guide-tabs-container {
+  &:not(#_) {
+    .nav-tabs, ul {
+      display: flex;
+      flex-wrap: nowrap;
+      justify-content: space-between;
+      width: 100%;
+      padding: 0;
+      margin: 0;
+      list-style: none;
+    }
+
+    .nav-item, li {
+      flex: 1 1 0;
+      min-width: 100px;
+      text-align: center;
+    }
+
+    .nav-link, button, a {
+      display: block;
+      width: 100%;
+      white-space: nowrap;
+    }
+  }
+}

+ 143 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/EditorGuideModal.tsx

@@ -0,0 +1,143 @@
+import {
+  type JSX,
+  type RefObject,
+  useLayoutEffect,
+  useMemo,
+  useState,
+} from 'react';
+import {
+  useEditorGuideModalActions,
+  useEditorGuideModalStatus,
+} from '@growi/editor/dist/states/modal/editor-guide';
+import { useTranslation } from 'react-i18next';
+import { Card, CardBody, CardHeader, Modal } from 'reactstrap';
+
+import { CustomNavTab } from '../../CustomNavigation/CustomNav';
+import CustomTabContent from '../../CustomNavigation/CustomTabContent';
+import { DecorationTab } from './contents/DecorationTab';
+import { LayoutTab } from './contents/LayoutTab';
+import { TextStyleTab } from './contents/TextStyleTab';
+
+import styles from './EditorGuideModal.module.scss';
+
+// Bootstrap $spacer (1rem = 16px) * 2 — matches calc(100% - 32px) padding on both sides
+const MODAL_MARGIN_PX = 32;
+// Bootstrap $modal-lg
+const MODAL_MAX_WIDTH = '800px';
+
+const TAB_TYPES = ['textstyle', 'layout', 'decoration'] as const;
+type TabType = (typeof TAB_TYPES)[number];
+type Props = {
+  containerRef: RefObject<HTMLDivElement | null>;
+};
+const isTabType = (key: string): key is TabType => {
+  return (TAB_TYPES as readonly string[]).includes(key);
+};
+
+const TextStyleTabPane = (): React.JSX.Element => <TextStyleTab />;
+const LayoutTabPane = (): React.JSX.Element => <LayoutTab />;
+const DecorationTabPane = (): React.JSX.Element => <DecorationTab />;
+
+export const EditorGuideModal = ({
+  containerRef,
+}: Props): JSX.Element | null => {
+  const { t } = useTranslation();
+  const { isOpened } = useEditorGuideModalStatus();
+  const { close } = useEditorGuideModalActions();
+  const [rect, setRect] = useState<DOMRect | null>(null);
+
+  const [activeTab, setActiveTab] = useState<TabType>('textstyle');
+  const navTabMapping = useMemo(() => {
+    return {
+      textstyle: {
+        i18n: t('editor_guide.tabs.textstyle'),
+        Content: TextStyleTabPane,
+      },
+      layout: {
+        i18n: t('editor_guide.tabs.layout'),
+        Content: LayoutTabPane,
+      },
+      decoration: {
+        i18n: t('editor_guide.tabs.decoration'),
+        Content: DecorationTabPane,
+      },
+    };
+  }, [t]);
+
+  useLayoutEffect(() => {
+    if (!isOpened || containerRef.current == null) return;
+
+    const updateRect = () => {
+      const r = containerRef.current?.getBoundingClientRect() ?? null;
+      setRect(r);
+      if (r != null) {
+        document.body.style.setProperty('--egm-top', `${r.top}px`);
+        document.body.style.setProperty('--egm-left', `${r.left}px`);
+        document.body.style.setProperty('--egm-width', `${r.width}px`);
+        document.body.style.setProperty('--egm-height', `${r.height}px`);
+      }
+    };
+    updateRect();
+    window.addEventListener('resize', updateRect);
+    return () => {
+      window.removeEventListener('resize', updateRect);
+      document.body.style.removeProperty('--egm-top');
+      document.body.style.removeProperty('--egm-left');
+      document.body.style.removeProperty('--egm-width');
+      document.body.style.removeProperty('--egm-height');
+    };
+  }, [isOpened, containerRef]);
+
+  if (!isOpened || rect == null) return null;
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={close}
+      keyboard
+      modalClassName={styles['editor-guide-modal']}
+      backdropClassName={styles['editor-guide-backdrop']}
+      contentClassName="bg-transparent border-0 shadow-none"
+      style={{
+        margin: 0,
+        maxWidth: MODAL_MAX_WIDTH,
+        width: `calc(100% - ${MODAL_MARGIN_PX}px)`,
+      }}
+    >
+      <Card
+        className="shadow-lg border-0"
+        style={{ maxHeight: rect.height - MODAL_MARGIN_PX }}
+      >
+        <CardHeader className="d-flex justify-content-between align-items-center bg-transparent border-bottom-0 pt-3">
+          <h5 className="mb-0 text-body">{t('editor_guide.title')}</h5>
+          <button
+            type="button"
+            className="btn-close"
+            onClick={close}
+            aria-label="Close"
+          />
+        </CardHeader>
+        <div className={`mt-2 px-3 ${styles['editor-guide-tabs-container']}`}>
+          <CustomNavTab
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            onNavSelected={(tabKey) => {
+              if (isTabType(tabKey)) {
+                setActiveTab(tabKey);
+              }
+            }}
+            hideBorderBottom
+          />
+        </div>
+        <CardBody
+          className={`pt-0 flex-fill ${styles['card-body-scrollable']}`}
+        >
+          <CustomTabContent
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+          />
+        </CardBody>
+      </Card>
+    </Modal>
+  );
+};

+ 21 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/components/GuideRow.module.scss

@@ -0,0 +1,21 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use 'sass:map';
+
+.copyButton {
+  display: block;
+  padding: 0;
+  text-align: left;
+  cursor: pointer;
+  background-color: transparent;
+  border: 0;
+}
+
+.codeBox {
+  width: fit-content;
+}
+
+.copyBadge {
+  top: map.get(bs.$spacers, 1);
+  right: map.get(bs.$spacers, 1);
+  font-size: bs.$badge-font-size;
+}

+ 89 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/components/GuideRow.tsx

@@ -0,0 +1,89 @@
+import type { ReactNode } from 'react';
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PrismAsyncLight } from 'react-syntax-highlighter';
+import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+
+import styles from './GuideRow.module.scss';
+
+export interface LayoutGuideItem {
+  id: string;
+  title?: string;
+  code?: string;
+  preview?: ReactNode;
+  minWidth?: string;
+  underContent?: ReactNode;
+}
+
+export type GuideRowProps = Omit<LayoutGuideItem, 'id'>;
+
+export const GuideRow = ({
+  title,
+  code,
+  preview,
+  minWidth = '230px',
+  underContent,
+}: GuideRowProps) => {
+  const { t } = useTranslation();
+  const handleCopy = useCallback(async () => {
+    try {
+      if (code == null) return;
+
+      await navigator.clipboard.writeText(code);
+      toastSuccess(t('editor_guide.textstyle.copy_done'));
+    } catch (_err) {
+      toastError(t('common:failed_to_copy'));
+    }
+  }, [code, t]);
+
+  const isFullWidth = minWidth === '100%' || !preview;
+
+  return (
+    <section className={title ? 'mt-4 mb-2' : 'mb-2'}>
+      {title && <h3 className="fw-bold mb-2 fs-5 text-body">{title}</h3>}
+      <div className="d-flex flex-row flex-wrap align-items-center gap-4 py-1">
+        <button
+          type="button"
+          onClick={handleCopy}
+          className={`${styles.copyButton} ${isFullWidth ? 'w-100 flex-grow-1' : 'flex-grow-0 flex-shrink-0'}`}
+          style={{ minWidth: isFullWidth ? '100%' : minWidth }}
+        >
+          {code != null && (
+            <div
+              className={`${styles.codeBox} rounded overflow-hidden position-relative ${isFullWidth ? 'w-100' : ''}`}
+            >
+              <PrismAsyncLight
+                style={oneDark}
+                language="markdown"
+                customStyle={{ margin: 0 }}
+                wrapLongLines={isFullWidth}
+              >
+                {code}
+              </PrismAsyncLight>
+              <small
+                className={`position-absolute badge bg-secondary opacity-50 ${styles.copyBadge}`}
+              >
+                Copy
+              </small>
+            </div>
+          )}
+          {code == null && (
+            <span className="text-secondary">
+              ({t('editor_guide.decoration.alert_unavailable')})
+            </span>
+          )}
+        </button>
+
+        {preview && (
+          <div className="flex-grow-1">
+            <div className="wiki-content small">{preview}</div>
+          </div>
+        )}
+      </div>
+
+      {underContent && <div className="mt-2 w-100">{underContent}</div>}
+    </section>
+  );
+};

+ 19 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/DecorationTab.module.scss

@@ -0,0 +1,19 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use 'sass:map';
+
+.decorationTab {
+  min-width: map.get(bs.$grid-breakpoints, 'sm');
+}
+
+// Override the margin of callout in the preview
+.decorationBody {
+  :global(.callout) {
+    margin: 0.2rem 0;
+  }
+}
+
+.dropdownMenu {
+  max-height: 300px;
+  margin-top: bs.$dropdown-spacer;
+  overflow-y: auto;
+}

+ 214 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/DecorationTab.tsx

@@ -0,0 +1,214 @@
+import type React from 'react';
+import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import ReactMarkdown from 'react-markdown';
+import {
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+} from 'reactstrap';
+
+import { usePreviewOptions } from '~/stores/renderer';
+
+import type { LayoutGuideItem } from '../components/GuideRow';
+import { GuideRow } from '../components/GuideRow';
+
+import styles from './DecorationTab.module.scss';
+
+const BOOTSTRAP_STYLES = [
+  'primary',
+  'secondary',
+  'info',
+  'success',
+  'warning',
+  'danger',
+] as const;
+type BOOTSTRAP_STYLES = (typeof BOOTSTRAP_STYLES)[number];
+
+const BOOTSTRAP_STYLES_TO_CONFIGS_MAPPINGS: Record<
+  BOOTSTRAP_STYLES,
+  { icon: string; calloutType?: string }
+> = {
+  primary: {
+    icon: 'feedback',
+    calloutType: 'important',
+  },
+  secondary: { icon: 'label' },
+  info: { icon: 'info', calloutType: 'note' },
+  success: { icon: 'lightbulb', calloutType: 'tip' },
+  warning: { icon: 'warning', calloutType: 'warning' },
+  danger: { icon: 'report', calloutType: 'caution' },
+};
+
+export const DecorationTab: React.FC = () => {
+  const { t } = useTranslation();
+  const i18nKey = 'editor_guide.decoration';
+  const [currentStyle, setCurrentStyle] = useState<BOOTSTRAP_STYLES>('primary');
+  const [isOpen, setIsOpen] = useState(false);
+
+  const { data: previewOptions } = usePreviewOptions();
+
+  const calloutConfig: { icon: string; calloutType?: string } =
+    BOOTSTRAP_STYLES_TO_CONFIGS_MAPPINGS[currentStyle];
+  const displayName =
+    currentStyle.charAt(0).toUpperCase() + currentStyle.slice(1);
+
+  const LAYOUT_GUIDES: LayoutGuideItem[] = useMemo(
+    () =>
+      [
+        currentStyle !== 'secondary' && {
+          id: 'alert',
+          title: t(`${i18nKey}.alert`),
+          code: `> [!${calloutConfig.calloutType?.toUpperCase()}]\n> ${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}`,
+          preview: (
+            <ReactMarkdown
+              {...previewOptions}
+            >{`> [!${calloutConfig.calloutType?.toUpperCase()}]\n> ${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}`}</ReactMarkdown>
+          ),
+        },
+        currentStyle !== 'secondary' && {
+          id: 'alert2',
+          code: `:::${calloutConfig.calloutType}\n${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}\n:::`,
+          preview: (
+            <ReactMarkdown
+              {...previewOptions}
+            >{`:::${calloutConfig.calloutType}\n${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}\n:::`}</ReactMarkdown>
+          ),
+        },
+        currentStyle !== 'secondary' && {
+          id: 'alert3',
+          title: t(`${i18nKey}.alert_with_custom_title`),
+          code: `:::${calloutConfig.calloutType}[${t(`${i18nKey}.alert_with_custom_title_text`)}]\n${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}\n:::`,
+          preview: (
+            <ReactMarkdown
+              {...previewOptions}
+            >{`:::${calloutConfig.calloutType}[${t(`${i18nKey}.alert_with_custom_title_text`)}]\n${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}\n:::`}</ReactMarkdown>
+          ),
+        },
+        currentStyle === 'secondary' && {
+          id: 'alert_empty',
+          title: t(`${i18nKey}.alert`),
+        },
+        {
+          id: 'badge',
+          title: t(`${i18nKey}.badge`),
+          code: `<span class="badge text-bg-${currentStyle}">${t(`${i18nKey}.badge`)}</span>`,
+          preview: (
+            <span className={`badge text-bg-${currentStyle}`}>
+              {t(`${i18nKey}.badge`)}
+            </span>
+          ),
+        },
+        {
+          id: 'text-color',
+          title: t(`${i18nKey}.text_color`),
+          code: `<p class="text-${currentStyle}">${t(`${i18nKey}.placeholder`)}</p>`,
+          underContent: (
+            <p className={`text-${currentStyle} m-0`}>
+              {t(`${i18nKey}.placeholder`)}
+            </p>
+          ),
+        },
+        {
+          id: 'back-color',
+          title: t(`${i18nKey}.back_color`),
+          code: `<p class="text-bg-${currentStyle}">${t(`${i18nKey}.placeholder`)}</p>`,
+          underContent: (
+            <p className={`text-bg-${currentStyle} px-2 m-0`}>
+              {t(`${i18nKey}.placeholder`)}
+            </p>
+          ),
+        },
+        {
+          id: 'alert-block',
+          title: t(`${i18nKey}.alert_block`),
+          code: `<div class="alert alert-${currentStyle}" role="alert">\n  ${t(`${i18nKey}.placeholder`)}\n</div>`,
+          underContent: (
+            <div className={`alert alert-${currentStyle} m-0`}>
+              {t(`${i18nKey}.placeholder`)}
+            </div>
+          ),
+        },
+      ].filter((item) => item !== false) as LayoutGuideItem[],
+    [currentStyle, t, previewOptions, calloutConfig.calloutType],
+  );
+
+  return (
+    <div className={`px-4 py-3 ${styles.decorationTab}`}>
+      <section className="mb-4">
+        <h3 className="fw-bold mb-2 fs-5">{t(`${i18nKey}.style`)}</h3>
+        <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
+          <DropdownToggle
+            outline
+            color="body"
+            caret
+            className={`border d-flex align-items-center gap-2 text-${currentStyle}`}
+            style={{ minWidth: '160px' }}
+          >
+            <span className="flex-grow-1 justify-content-start d-flex align-items-center gap-1">
+              <span className="material-symbols-outlined align-middle fs-6">
+                {calloutConfig.icon}
+              </span>
+              {displayName}
+            </span>
+          </DropdownToggle>
+          <DropdownMenu className={styles.dropdownMenu}>
+            {BOOTSTRAP_STYLES.map((style) => (
+              <DropdownItem
+                key={style}
+                active={currentStyle === style}
+                className="d-flex align-items-center gap-2"
+                onClick={() => setCurrentStyle(style)}
+              >
+                <span className="material-symbols-outlined">
+                  {BOOTSTRAP_STYLES_TO_CONFIGS_MAPPINGS[style].icon}
+                </span>
+                {style.charAt(0).toUpperCase() + style.slice(1)}
+              </DropdownItem>
+            ))}
+          </DropdownMenu>
+        </Dropdown>
+      </section>
+
+      <hr />
+
+      <div key={currentStyle} className={styles.decorationBody}>
+        {LAYOUT_GUIDES.map((item) => (
+          <GuideRow key={item.id} {...item} minWidth="280px" />
+        ))}
+      </div>
+
+      <div className="mt-5 pt-3 border-top">
+        <h3 className="fw-bold fs-5 mb-3">{t(`${i18nKey}.docs_title`)}</h3>
+        <div className="d-flex flex-column gap-2">
+          {[
+            {
+              key: 'badge',
+              url: 'https://getbootstrap.com/docs/5.3/components/badge/',
+            },
+            {
+              key: 'color',
+              url: 'https://getbootstrap.com/docs/5.3/utilities/colors/',
+            },
+            {
+              key: 'alert',
+              url: 'https://getbootstrap.com/docs/5.3/components/alerts/',
+            },
+          ].map(({ key, url }) => (
+            <a
+              key={key}
+              href={url}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="text-decoration-none text-secondary small d-flex align-items-center"
+            >
+              {t(`${i18nKey}.docs_${key}`)}
+              <span className="material-symbols-outlined">open_in_new</span>
+            </a>
+          ))}
+        </div>
+      </div>
+    </div>
+  );
+};

+ 18 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/LayoutTab.module.scss

@@ -0,0 +1,18 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use 'sass:map';
+
+.layoutTabContainer {
+  min-width: map.get(bs.$grid-breakpoints, 'sm');
+}
+
+.checkboxMock {
+  width: bs.$form-check-input-width;
+  height: bs.$form-check-input-width;
+}
+
+.tableContainer {
+  width: fit-content;
+  table {
+    min-width: map.get(bs.$grid-breakpoints, 'sm');
+  }
+}

+ 230 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/LayoutTab.tsx

@@ -0,0 +1,230 @@
+import type React from 'react';
+import { useTranslation } from 'react-i18next';
+import { PrismAsyncLight } from 'react-syntax-highlighter';
+import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+
+import type { LayoutGuideItem } from '../components/GuideRow';
+import { GuideRow } from '../components/GuideRow';
+
+import styles from './LayoutTab.module.scss';
+
+export const LayoutTab: React.FC = () => {
+  const { t } = useTranslation();
+  const i18nKey = 'editor_guide.layout';
+
+  const LAYOUT_GUIDES: LayoutGuideItem[] = [
+    {
+      id: 'header',
+      title: t(`${i18nKey}.header`),
+      code: [
+        `# ${t(`${i18nKey}.header_text`)}1`,
+        `## ${t(`${i18nKey}.header_text`)}2`,
+        `### ${t(`${i18nKey}.header_text`)}3`,
+        `#### ${t(`${i18nKey}.header_text`)}4`,
+        `##### ${t(`${i18nKey}.header_text`)}5`,
+        `###### ${t(`${i18nKey}.header_text`)}6`,
+      ].join('\n'),
+      preview: (
+        <div className="text-body lh-base">
+          <h1 className="h2 border-bottom pb-1 mb-2 fw-normal">
+            {t(`${i18nKey}.header_text`)}1
+          </h1>
+          <h2 className="h3 border-bottom pb-1 mb-2 fw-bold">
+            {t(`${i18nKey}.header_text`)}2
+          </h2>
+          <h3 className="fs-5 mb-2 fw-bold">{t(`${i18nKey}.header_text`)}3</h3>
+          <h4 className="fs-5 border-start border-4 ps-2 mb-2 fw-normal border-secondary-subtle">
+            {t(`${i18nKey}.header_text`)}4
+          </h4>
+          <h5 className="fs-5 border-start border-4 ps-2 mb-2 fw-normal border-secondary-subtle">
+            {t(`${i18nKey}.header_text`)}5
+          </h5>
+          <h6 className="fs-6 border-start border-4 ps-2 mb-0 fw-normal border-secondary-subtle">
+            {t(`${i18nKey}.header_text`)}6
+          </h6>
+        </div>
+      ),
+    },
+    {
+      id: 'list',
+      title: t(`${i18nKey}.list`),
+      code: `- ${t(`${i18nKey}.list_text`)}\n  * ${t(`${i18nKey}.list_text`)}\n    + ${t(`${i18nKey}.list_text`)}`,
+      preview: (
+        <ul className="mb-0" style={{ listStyleType: 'disc' }}>
+          <li>
+            {t(`${i18nKey}.list_text`)}
+            <ul className="mt-1" style={{ listStyleType: 'disc' }}>
+              <li>
+                {t(`${i18nKey}.list_text`)}
+                <ul className="mt-1" style={{ listStyleType: 'disc' }}>
+                  <li>{t(`${i18nKey}.list_text`)}</li>
+                </ul>
+              </li>
+            </ul>
+          </li>
+        </ul>
+      ),
+    },
+    {
+      id: 'ordered-list',
+      title: t(`${i18nKey}.ordered_list`),
+      code: `1. ${t(`${i18nKey}.ordered_list_text`)}\n1. ${t(`${i18nKey}.ordered_list_text`)}\n1. ${t(`${i18nKey}.ordered_list_text`)}`,
+      preview: (
+        <ol className="ps-3 mb-0 text-body">
+          <li className="mb-2">{t(`${i18nKey}.ordered_list_text`)}</li>
+          <li className="mb-2">{t(`${i18nKey}.ordered_list_text`)}</li>
+          <li className="mb-0">{t(`${i18nKey}.ordered_list_text`)}</li>
+        </ol>
+      ),
+    },
+    {
+      id: 'checkbox',
+      title: t(`${i18nKey}.checkbox`),
+      code: `[x] ${t(`${i18nKey}.task`)}1\n  [] ${t(`${i18nKey}.task`)}1-1\n  [x] ${t(`${i18nKey}.task`)}1-2`,
+      preview: (
+        <div className="text-body fs-6 lh-lg">
+          <div className="d-flex align-items-center mb-1">
+            <span className="me-2 user-select-none">☑️</span>
+            <span>{t(`${i18nKey}.task`)}1</span>
+          </div>
+          <div className="d-flex align-items-center mb-1 ps-4">
+            <span
+              className={`d-inline-block border border-secondary rounded me-2 ${styles.checkboxMock}`}
+            />
+            <span>{t(`${i18nKey}.task`)}1-1</span>
+          </div>
+          <div className="d-flex align-items-center ps-4">
+            <span className="me-2 user-select-none">☑️</span>
+            <span>{t(`${i18nKey}.task`)}1-2</span>
+          </div>
+        </div>
+      ),
+    },
+    {
+      id: 'quote',
+      title: t(`${i18nKey}.quote`),
+      code: `> ${t(`${i18nKey}.quote_text`)}\n>> ${t(`${i18nKey}.multi_quote`)}`,
+      preview: (
+        <blockquote className="border-start border-4 ps-3 text-muted fst-italic border-secondary-subtle">
+          {t(`${i18nKey}.quote_text`)}
+          <blockquote className="border-start border-2 ps-3 mt-2">
+            {t(`${i18nKey}.multi_quote`)}
+          </blockquote>
+        </blockquote>
+      ),
+    },
+    {
+      id: 'hr',
+      title: t(`${i18nKey}.hr`),
+      code: '***\n\n―――\n\n---',
+      preview: (
+        <div className="d-flex flex-column gap-4 w-100">
+          <hr className="my-0 opacity-25" />
+          <hr className="my-0 opacity-25" />
+          <hr className="my-0 opacity-25" />
+        </div>
+      ),
+    },
+    {
+      id: 'br',
+      title: t(`${i18nKey}.br`),
+      code: t(`${i18nKey}.br_code`),
+      preview: (
+        <div className="text-body lh-base">
+          {t(`${i18nKey}.br_preview_1`)}
+          <br />
+          {t(`${i18nKey}.br_preview_2`)}
+        </div>
+      ),
+    },
+    {
+      id: 'code-block',
+      title: t(`${i18nKey}.code_block`),
+      code: `\`\`\`\n${t(`${i18nKey}.code_block_text`)}\n\`\`\``,
+      preview: (
+        <PrismAsyncLight
+          style={oneDark}
+          language="markdown"
+          customStyle={{ margin: 0 }}
+        >
+          {t(`${i18nKey}.code_block_text`)}
+        </PrismAsyncLight>
+      ),
+    },
+    {
+      id: 'table1',
+      title: t(`${i18nKey}.table`),
+      code: [
+        `| ${t(`${i18nKey}.left`)} | ${t(`${i18nKey}.right`)} | ${t(`${i18nKey}.center`)} |`,
+        '|:-------------- | --------------:| :--------------: |',
+        `| ${t(`${i18nKey}.row_text`)} | ${t(`${i18nKey}.row_text`)} | ${t(`${i18nKey}.row_text`)} |`,
+        `| ${t(`${i18nKey}.left`)}${t(`${i18nKey}.row_display`)} | ${t(`${i18nKey}.right`)}${t(`${i18nKey}.row_display`)} | ` +
+          `${t(`${i18nKey}.center`)}${t(`${i18nKey}.row_display`)} |`,
+      ].join('\n'),
+      underContent: (
+        <div className={`table-responsive mt-2 ${styles.tableContainer}`}>
+          <table className="table table-sm table-bordered mb-0 small text-body">
+            <thead>
+              <tr className="table-light">
+                <th className="text-start fw-bold p-2 align-middle">
+                  {t(`${i18nKey}.left`)}
+                </th>
+                <th className="text-end fw-bold p-2 align-middle">
+                  {t(`${i18nKey}.right`)}
+                </th>
+                <th className="text-center fw-bold p-2 align-middle">
+                  {t(`${i18nKey}.center`)}
+                </th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td className="text-start p-2">{t(`${i18nKey}.row_text`)}</td>
+                <td className="text-end p-2">{t(`${i18nKey}.row_text`)}</td>
+                <td className="text-center p-2">{t(`${i18nKey}.row_text`)}</td>
+              </tr>
+              <tr>
+                <td className="text-start p-2">
+                  {t(`${i18nKey}.left`)}
+                  {t(`${i18nKey}.row_display`)}
+                </td>
+                <td className="text-end p-2">
+                  {t(`${i18nKey}.right`)}
+                  {t(`${i18nKey}.row_display`)}
+                </td>
+                <td className="text-center p-2">
+                  {t(`${i18nKey}.center`)}
+                  {t(`${i18nKey}.row_display`)}
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      ),
+    },
+    {
+      id: 'footnote',
+      title: t(`${i18nKey}.footnote`),
+      code: `${t(`${i18nKey}.footnote_label`)}[^1].\n\n[^1]: ${t(`${i18nKey}.footnote_desc`)}.`,
+      preview: (
+        <div className="text-body fs-6 lh-base">
+          {t(`${i18nKey}.footnote_label`)}
+          <sup className="ms-1 text-body small">[1]</sup>
+        </div>
+      ),
+      underContent: (
+        <div className="text-body-secondary small mt-1">
+          1. {t(`${i18nKey}.footnote_desc`)}
+        </div>
+      ),
+    },
+  ];
+
+  return (
+    <div className={`px-4 py-3 overflow-y-auto ${styles.layoutTabContainer}`}>
+      {LAYOUT_GUIDES.map((item) => (
+        <GuideRow key={item.id} {...item} />
+      ))}
+    </div>
+  );
+};

+ 20 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/TextStyleTab.module.scss

@@ -0,0 +1,20 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use 'sass:map';
+
+.codeBlockWrapper {
+  width: fit-content;
+}
+
+.copyBadge {
+  top: bs.$dropdown-spacer;
+  right: map.get(bs.$spacers, 1);
+  font-size: bs.$badge-font-size;
+}
+
+.wikiPreview {
+  font-size: bs.$font-size-sm;
+}
+
+.inlineCodeLabel {
+  border: 1px solid currentColor;
+}

+ 219 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/TextStyleTab.tsx

@@ -0,0 +1,219 @@
+import type React from 'react';
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PrismAsyncLight } from 'react-syntax-highlighter';
+import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useGrowiDocumentationUrl } from '~/states/context';
+import { getLocale } from '~/utils/locale-utils';
+
+import styles from './TextStyleTab.module.scss';
+
+const GuideRow = ({
+  title,
+  code,
+  preview,
+}: {
+  title: string;
+  code: string;
+  preview: React.ReactNode;
+}) => {
+  const { t } = useTranslation();
+  const handleCopy = useCallback(async () => {
+    try {
+      await navigator.clipboard.writeText(code);
+      toastSuccess(t('editor_guide.textstyle.copy_done'));
+    } catch (_err) {
+      toastError(t('common:failed_to_copy'));
+    }
+  }, [code, t]);
+
+  return (
+    <section className={title !== '' ? 'mt-4 mb-1' : 'mb-1'}>
+      {title !== '' && <h3 className="h6 fw-bold mb-2">{title}</h3>}
+      <div className="d-flex flex-row align-items-center gap-3 py-1 flex-nowrap">
+        <button
+          type="button"
+          onClick={handleCopy}
+          className="flex-shrink-0 border-0 p-0 bg-transparent text-start cursor-pointer"
+        >
+          <div
+            className={`rounded position-relative overflow-hidden ${styles.codeBlockWrapper}`}
+          >
+            <PrismAsyncLight
+              style={oneDark}
+              language="markdown"
+              customStyle={{ margin: 0 }}
+            >
+              {code}
+            </PrismAsyncLight>
+            <small
+              className={`position-absolute badge bg-secondary opacity-50 ${styles.copyBadge}`}
+            >
+              Copy
+            </small>
+          </div>
+        </button>
+        <div className="flex-grow-1 text-nowrap">
+          <div className={`wiki-content fw-normal ${styles.wikiPreview}`}>
+            {preview}
+          </div>
+        </div>
+      </div>
+    </section>
+  );
+};
+
+export const TextStyleTab: React.FC = () => {
+  const { t, i18n } = useTranslation();
+  const documentationUrl = useGrowiDocumentationUrl();
+  const docsLang = getLocale(i18n.language).code === 'ja' ? 'ja' : 'en';
+  const i18nKey = 'editor_guide.textstyle';
+
+  const TEXT_STYLE_GUIDES = [
+    {
+      id: 'bold',
+      title: t(`${i18nKey}.bold`),
+      code: `${t(
+        `${i18nKey}.this`,
+      )} **${t(`${i18nKey}.bold`)}** ${t(`${i18nKey}.is`)}\n${t(`${i18nKey}.this`)} __${t(`${i18nKey}.bold`)}__ ${t(`${i18nKey}.is`)}`,
+      preview: (
+        <div className="lh-base">
+          {t(`${i18nKey}.this`)} <strong>{t(`${i18nKey}.bold`)}</strong>{' '}
+          {t(`${i18nKey}.is`)}
+          <br />
+          {t(`${i18nKey}.this`)} <strong>{t(`${i18nKey}.bold`)}</strong>{' '}
+          {t(`${i18nKey}.is`)}
+        </div>
+      ),
+    },
+    {
+      id: 'italic',
+      title: t(`${i18nKey}.italic`),
+      code: `${t(
+        `${i18nKey}.this`,
+      )} *${t(`${i18nKey}.italic`)}*${t(`${i18nKey}.is`)}\n${t(`${i18nKey}.this`)} _${t(`${i18nKey}.italic`)}_${t(`${i18nKey}.is`)}`,
+      preview: (
+        <div className="lh-base">
+          {t(`${i18nKey}.this`)} <em>{t(`${i18nKey}.italic`)}</em>{' '}
+          {t(`${i18nKey}.is`)}
+          <br />
+          {t(`${i18nKey}.this`)} <em>{t(`${i18nKey}.italic`)}</em>{' '}
+          {t(`${i18nKey}.is`)}
+        </div>
+      ),
+    },
+    {
+      id: 'strikethrough',
+      title: t(`${i18nKey}.strikethrough`),
+      code: `~~${t(`${i18nKey}.strikethrough`)}~~`,
+      preview: <del>{t(`${i18nKey}.strikethrough`)}</del>,
+    },
+    {
+      id: 'inline-code',
+      title: t(`${i18nKey}.inline_code`),
+      code: `\`${t(`${i18nKey}.inline_code`)}\` \n~~~${t(`${i18nKey}.inline_code`)}~~~`,
+      preview: (
+        <div className="d-flex flex-column gap-2 align-items-start">
+          <code
+            className={`rounded px-1 d-inline-block bg-transparent ${styles.inlineCodeLabel}`}
+          >
+            {t(`${i18nKey}.inline_code`)}
+          </code>
+          <code
+            className={`rounded px-1 d-inline-block bg-transparent ${styles.inlineCodeLabel}`}
+          >
+            {t(`${i18nKey}.inline_code`)}
+          </code>
+        </div>
+      ),
+    },
+    {
+      id: 'bold-italic',
+      title: t(`${i18nKey}.bold_italic`),
+      code: `***${t(`${i18nKey}.all_important`)}***`,
+      preview: (
+        <strong>
+          <em>{t(`${i18nKey}.all_important`).replace('\n', '')}</em>
+        </strong>
+      ),
+    },
+    {
+      id: 'emoji',
+      title: t(`${i18nKey}.emoji`),
+      code: ':+1:\n:white_check_mark:\n:lock:',
+      preview: <span style={{ fontSize: '1.2rem' }}>👍✅🔒</span>,
+    },
+    {
+      id: 'sub',
+      title: t(`${i18nKey}.sub_sup`),
+      code: t(`${i18nKey}.is_text`, {
+        val: `<sub>${t(`${i18nKey}.sub_text`)}</sub>`,
+      }),
+      preview: (
+        <span>
+          {t(`${i18nKey}.this`)} <sub>{t(`${i18nKey}.sub_text`)}</sub>{' '}
+          {t(`${i18nKey}.is`)}
+        </span>
+      ),
+    },
+    {
+      id: 'sup',
+      title: '',
+      code: t(`${i18nKey}.is_text`, {
+        val: `<sup>${t(`${i18nKey}.sup_text`)}</sup>`,
+      }),
+      preview: (
+        <span>
+          {t(`${i18nKey}.this`)} <sup>{t(`${i18nKey}.sup_text`)}</sup>{' '}
+          {t(`${i18nKey}.is`)}
+        </span>
+      ),
+    },
+    {
+      id: 'link-docs',
+      title: t(`${i18nKey}.link_label`),
+      code: `[${t(`${i18nKey}.link_docs`)}](${documentationUrl}/${docsLang})`,
+      preview: (
+        <a
+          href={`${documentationUrl}/${docsLang}`}
+          target="_blank"
+          rel="noreferrer"
+          className="text-secondary text-decoration-underline"
+          onClick={(e) => e.stopPropagation()}
+        >
+          {t(`${i18nKey}.link_growi`)}
+          <span className="material-symbols-outlined">open_in_new</span>
+        </a>
+      ),
+    },
+    {
+      id: 'link-sandbox',
+      title: '',
+      code: `[${t(`${i18nKey}.link_sandbox`)}](/Sandbox)`,
+      preview: (
+        <a
+          href="/Sandbox"
+          className="text-secondary text-decoration-underline"
+          onClick={(e) => e.stopPropagation()}
+        >
+          {t(`${i18nKey}.link_sandbox`)}
+          <span className="material-symbols-outlined">open_in_new</span>
+        </a>
+      ),
+    },
+  ];
+  return (
+    <div className="px-4 py-2">
+      {TEXT_STYLE_GUIDES.map((item) => (
+        <GuideRow
+          key={item.id}
+          title={item.title}
+          code={item.code}
+          preview={item.preview}
+        />
+      ))}
+    </div>
+  );
+};

+ 29 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/dynamic.tsx

@@ -0,0 +1,29 @@
+import type { JSX, RefObject } from 'react';
+import { useEditorGuideModalStatus } from '@growi/editor/dist/states/modal/editor-guide';
+
+import { useLazyLoader } from '~/components/utils/use-lazy-loader';
+
+type Props = {
+  containerRef: RefObject<HTMLDivElement | null>;
+};
+
+export const EditorGuideModalLazyLoaded = ({
+  containerRef,
+}: Props): JSX.Element => {
+  const { isOpened } = useEditorGuideModalStatus();
+
+  const EditorGuideModal = useLazyLoader(
+    'editor-guide-modal',
+    () =>
+      import('./EditorGuideModal').then((mod) => ({
+        default: mod.EditorGuideModal,
+      })),
+    isOpened,
+  );
+
+  return EditorGuideModal != null ? (
+    <EditorGuideModal containerRef={containerRef} />
+  ) : (
+    <></>
+  );
+};

+ 1 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/index.ts

@@ -0,0 +1 @@
+export { EditorGuideModalLazyLoaded } from './dynamic';

+ 35 - 31
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -70,6 +70,7 @@ import {
   useConflictEffect,
   useConflictResolver,
 } from './conflict';
+import { EditorGuideModalLazyLoaded } from './EditorGuideModal/dynamic';
 import { EditorNavbar } from './EditorNavbar';
 import { EditorNavbarBottom } from './EditorNavbarBottom';
 import Preview from './Preview';
@@ -462,38 +463,41 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   }
 
   return (
-    <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
-      <div className="page-editor-editor-container flex-expand-vert border-end">
-        <CodeMirrorEditorMain
-          enableUnifiedMergeView={isEnableUnifiedMergeView}
-          enableCollaboration={editorMode === EditorMode.Editor}
-          onSave={saveWithShortcut}
-          onUpload={uploadHandler}
-          acceptedUploadFileType={acceptedUploadFileType}
-          onScroll={scrollEditorHandlerThrottle}
-          indentSize={currentIndentSize ?? defaultIndentSize}
-          user={user ?? undefined}
-          pageId={pageId ?? undefined}
-          editorSettings={editorSettings}
-          onEditorsUpdated={setEditingClients}
-          onScrollToRemoteCursorReady={setScrollToRemoteCursor}
-          cmProps={cmProps}
-        />
+    <>
+      <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
+        <div className="page-editor-editor-container flex-expand-vert border-end">
+          <CodeMirrorEditorMain
+            enableUnifiedMergeView={isEnableUnifiedMergeView}
+            enableCollaboration={editorMode === EditorMode.Editor}
+            onSave={saveWithShortcut}
+            onUpload={uploadHandler}
+            acceptedUploadFileType={acceptedUploadFileType}
+            onScroll={scrollEditorHandlerThrottle}
+            indentSize={currentIndentSize ?? defaultIndentSize}
+            user={user ?? undefined}
+            pageId={pageId ?? undefined}
+            editorSettings={editorSettings}
+            onEditorsUpdated={setEditingClients}
+            onScrollToRemoteCursorReady={setScrollToRemoteCursor}
+            cmProps={cmProps}
+          />
+        </div>
+        <div
+          ref={previewRef}
+          onScroll={scrollPreviewHandlerThrottle}
+          className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex position-relative"
+        >
+          <Preview
+            rendererOptions={rendererOptions}
+            markdown={markdownToPreview}
+            pagePath={currentPagePath}
+            expandContentWidth={shouldExpandContent}
+            style={pastEndStyle}
+          />
+        </div>
       </div>
-      <div
-        ref={previewRef}
-        onScroll={scrollPreviewHandlerThrottle}
-        className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
-      >
-        <Preview
-          rendererOptions={rendererOptions}
-          markdown={markdownToPreview}
-          pagePath={currentPagePath}
-          expandContentWidth={shouldExpandContent}
-          style={pastEndStyle}
-        />
-      </div>
-    </div>
+      <EditorGuideModalLazyLoaded containerRef={previewRef} />
+    </>
   );
 };
 

+ 4 - 0
packages/custom-icons/svg/editor_guide.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+<defs><style>.a{fill:none;}</style></defs>
+<path d="M10.2163 1.15039C11.7248 0.903833 13.1901 0.947105 14.5825 1.32422C15.9893 1.65785 17.2657 2.25241 18.397 3.0791C19.5428 3.87686 20.5149 4.90689 21.2837 6.1543C22.0814 7.35815 22.6035 8.70726 22.8501 10.2012C23.0967 11.7096 23.0379 13.1606 22.6753 14.582C22.3562 15.9888 21.7617 17.28 20.9351 18.4258C20.1228 19.557 19.0926 20.5138 17.8599 21.3115C16.656 22.0802 15.3069 22.603 13.813 22.8496C12.3047 23.0961 10.8544 23.0376 9.43311 22.7041C8.72237 22.53 8.06936 22.2981 7.43115 22.0225L1.04932 22.9072L2.49951 17.541C1.83236 16.4098 1.38298 15.162 1.15088 13.7842C0.904307 12.2757 0.947333 10.8104 1.29541 9.41797C1.65798 8.01121 2.23821 6.73477 3.05029 5.60352C3.877 4.45772 4.89271 3.47102 6.12549 2.7168C7.3584 1.91903 8.70781 1.39697 10.2163 1.15039ZM17.4976 4.29785C15.3944 2.80386 13.0443 2.25221 10.4624 2.67285C7.86616 3.10804 5.83594 4.38515 4.31299 6.48828C2.8191 8.59138 2.28208 10.9408 2.70264 13.5225C2.78966 14.1171 2.93499 14.6829 3.12354 15.2051C3.35561 15.9158 3.68908 16.5832 4.09521 17.1924L3.5874 19.0781L3.02197 21.1523L5.41455 20.8184L7.8667 20.4854C8.43226 20.7753 9.01238 20.9927 9.60693 21.1377C10.8543 21.4568 12.1452 21.5152 13.5376 21.2832C16.1195 20.8626 18.1648 19.6003 19.6733 17.4971V17.4824C21.1672 15.3793 21.7324 13.0298 21.2974 10.4336C20.8767 7.85178 19.6007 5.80634 17.4976 4.29785ZM12.7983 17.6279H11.2173V10.666H12.7983V17.6279ZM12.0151 7.14062C12.2471 7.14067 12.4504 7.22833 12.6099 7.40234C12.7692 7.57632 12.856 7.76497 12.856 7.98242C12.8559 8.22878 12.7692 8.43169 12.6099 8.62012C12.4504 8.77963 12.2471 8.8525 12.0151 8.85254C11.7686 8.85254 11.5653 8.77967 11.4058 8.62012C11.2463 8.43166 11.1587 8.22883 11.1587 7.98242C11.1587 7.73585 11.2462 7.5471 11.4058 7.37305C11.5653 7.21349 11.7686 7.14062 12.0151 7.14062Z"/>
+</svg>

+ 31 - 0
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/EditorGuideButton.tsx

@@ -0,0 +1,31 @@
+import { type JSX, useCallback, useId } from 'react';
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { useEditorGuideModalActions } from '../../../../states/modal/editor-guide';
+
+export const EditorGuideButton = (): JSX.Element => {
+  const { open: openEditorGuideModal } = useEditorGuideModalActions();
+  const id = useId();
+  const { t } = useTranslation('translation');
+
+  const onClickEditorGuideButton = useCallback(() => {
+    openEditorGuideModal();
+  }, [openEditorGuideModal]);
+
+  return (
+    <div className="d-none d-lg-block">
+      <button
+        id={id}
+        type="button"
+        className="btn btn-toolbar-button"
+        onClick={onClickEditorGuideButton}
+      >
+        <span className="growi-custom-icons fs-6">editor_guide</span>
+      </button>
+      <UncontrolledTooltip placement="top" target={CSS.escape(id)}>
+        {t('toolbar.editor_guide')}
+      </UncontrolledTooltip>
+    </div>
+  );
+};

+ 2 - 0
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/Toolbar.tsx

@@ -5,6 +5,7 @@ import SimpleBar from 'simplebar-react';
 import type { GlobalCodeMirrorEditorKey } from '../../../../consts';
 import { AttachmentsDropup } from './AttachmentsDropup';
 import { DiagramButton } from './DiagramButton';
+import { EditorGuideButton } from './EditorGuideButton';
 import { EmojiButton } from './EmojiButton';
 import { TableButton } from './TableButton';
 import { TemplateButton } from './TemplateButton';
@@ -55,6 +56,7 @@ export const Toolbar = memo((props: Props): JSX.Element => {
               <TableButton editorKey={editorKey} />
               <DiagramButton editorKey={editorKey} />
               <TemplateButton editorKey={editorKey} />
+              <EditorGuideButton />
             </div>
           </SimpleBar>
         </div>

+ 28 - 0
packages/editor/src/states/modal/editor-guide.ts

@@ -0,0 +1,28 @@
+import { useMemo } from 'react';
+import { atom, useAtomValue, useSetAtom } from 'jotai';
+
+export type EditorGuideModalState = {
+  isOpened: boolean;
+};
+
+const editorGuideModalAtom = atom<EditorGuideModalState>({
+  isOpened: false,
+});
+
+const openEditorGuideModalAtom = atom(null, (_get, set) => {
+  set(editorGuideModalAtom, { isOpened: true });
+});
+
+const closeEditorGuideModalAtom = atom(null, (_get, set) => {
+  set(editorGuideModalAtom, { isOpened: false });
+});
+
+export const useEditorGuideModalStatus = () => {
+  return useAtomValue(editorGuideModalAtom);
+};
+
+export const useEditorGuideModalActions = () => {
+  const open = useSetAtom(openEditorGuideModalAtom);
+  const close = useSetAtom(closeEditorGuideModalAtom);
+  return useMemo(() => ({ open, close }), [open, close]);
+};