Browse Source

Merge pull request #10779 from growilabs/feat/160341-172821-textstyletab

160341-172821 Implement Text Style Tab
Yuki Takei 3 months ago
parent
commit
ffe6544e07

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

@@ -1072,5 +1072,33 @@
     "success-toaster": "Latest text synchronized",
     "success-toaster": "Latest text synchronized",
     "skipped-toaster": "Skipped synchronizing since the editor is not activated. Please open the editor and try again.",
     "skipped-toaster": "Skipped synchronizing since the editor is not activated. Please open the editor and try again.",
     "error-toaster": "Synchronization of the latest text failed"
     "error-toaster": "Synchronization of the latest text failed"
+  },
+
+  "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"
+    }
   }
   }
 }
 }

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

@@ -1063,5 +1063,33 @@
     "success-toaster": "Dernier texte synchronisé",
     "success-toaster": "Dernier texte synchronisé",
     "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
     "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
     "error-toaster": "Synchronisation échouée"
     "error-toaster": "Synchronisation échouée"
+  },
+
+  "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"
+    }
   }
   }
 }
 }

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

@@ -1105,5 +1105,33 @@
     "success-toaster": "最新の本文を同期しました",
     "success-toaster": "最新の本文を同期しました",
     "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
     "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
     "error-toaster": "最新の本文の同期に失敗しました"
     "error-toaster": "最新の本文の同期に失敗しました"
+  },
+
+  "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": "上付き"
+    }
   }
   }
 }
 }

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

@@ -1032,5 +1032,32 @@
     "success-toaster": "최신 텍스트 동기화됨",
     "success-toaster": "최신 텍스트 동기화됨",
     "skipped-toaster": "편집기가 활성화되지 않아 동기화 건너뜀. 편집기를 열고 다시 시도하십시오.",
     "skipped-toaster": "편집기가 활성화되지 않아 동기화 건너뜀. 편집기를 열고 다시 시도하십시오.",
     "error-toaster": "최신 텍스트 동기화 실패"
     "error-toaster": "최신 텍스트 동기화 실패"
+  },
+  "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": "위 첨자"
+    }
   }
   }
 }
 }

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

@@ -1077,5 +1077,33 @@
     "success-toaster": "同步最新文本",
     "success-toaster": "同步最新文本",
     "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
     "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
     "error-toaster": "同步最新文本失败"
     "error-toaster": "同步最新文本失败"
+  },
+
+  "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": "上标"
+    }
   }
   }
 }
 }

+ 50 - 5
apps/app/src/client/components/PageEditor/EditorGuideModal/EditorGuideModal.tsx

@@ -1,13 +1,27 @@
 import {
 import {
-  useState, useEffect, useLayoutEffect, type JSX, type RefObject,
+  useState, useEffect, useLayoutEffect, type JSX, type RefObject, useMemo,
 } from 'react';
 } from 'react';
 
 
+
 import { useEditorGuideModalStatus, useEditorGuideModalActions } from '@growi/editor/dist/states/modal/editor-guide';
 import { useEditorGuideModalStatus, useEditorGuideModalActions } from '@growi/editor/dist/states/modal/editor-guide';
 import { createPortal } from 'react-dom';
 import { createPortal } from 'react-dom';
+import { useTranslation } from 'react-i18next';
+
+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';
 
 
+const TAB_TYPES = ['textstyle', 'layout', 'decoration'] as const;
+type TabType = (typeof TAB_TYPES)[number];
 type Props = {
 type Props = {
   containerRef: RefObject<HTMLDivElement | null>,
   containerRef: RefObject<HTMLDivElement | null>,
 };
 };
+const isTabType = (key: string): key is TabType => {
+  return (TAB_TYPES as readonly string[]).includes(key);
+};
 
 
 /**
 /**
  * EditorGuideModal
  * EditorGuideModal
@@ -16,11 +30,30 @@ type Props = {
  * not the entire screen. Uses createPortal to render into document.body.
  * not the entire screen. Uses createPortal to render into document.body.
  */
  */
 export const EditorGuideModal = ({ containerRef }: Props): JSX.Element => {
 export const EditorGuideModal = ({ containerRef }: Props): JSX.Element => {
+  const { t } = useTranslation();
   const { isOpened } = useEditorGuideModalStatus();
   const { isOpened } = useEditorGuideModalStatus();
   const { close } = useEditorGuideModalActions();
   const { close } = useEditorGuideModalActions();
   const [isShown, setIsShown] = useState(false);
   const [isShown, setIsShown] = useState(false);
   const [rect, setRect] = useState<DOMRect | null>(null);
   const [rect, setRect] = useState<DOMRect | null>(null);
 
 
+  const [activeTab, setActiveTab] = useState<TabType>('textstyle');
+  const navTabMapping = useMemo((): Record<TabType, { i18n: string, Content: () => JSX.Element }> => {
+    return {
+      textstyle: {
+        i18n: t('editor_guide.tabs.textstyle'),
+        Content: () => <TextStyleTab />,
+      },
+      layout: {
+        i18n: t('editor_guide.tabs.layout'),
+        Content: () => <LayoutTab />,
+      },
+      decoration: {
+        i18n: t('editor_guide.tabs.decoration'),
+        Content: () => <DecorationTab />,
+      },
+    };
+  }, [t]);
+
   // Get rect on open and on resize
   // Get rect on open and on resize
   useLayoutEffect(() => {
   useLayoutEffect(() => {
     if (!isOpened || containerRef.current == null) return;
     if (!isOpened || containerRef.current == null) return;
@@ -57,11 +90,23 @@ export const EditorGuideModal = ({ containerRef }: Props): JSX.Element => {
               <h5 className="mb-0">Editor Guide</h5>
               <h5 className="mb-0">Editor Guide</h5>
               <button type="button" className="btn-close" onClick={close} aria-label="Close" />
               <button type="button" className="btn-close" onClick={close} aria-label="Close" />
             </div>
             </div>
+            <div className="mt-2 px-3">
+              <CustomNavTab
+                activeTab={activeTab}
+                navTabMapping={navTabMapping}
+                onNavSelected={(tabKey) => {
+                  if (isTabType(tabKey)) {
+                    setActiveTab(tabKey);
+                  }
+                }}
+                hideBorderBottom
+              />
+            </div>
             <div className="card-body overflow-auto">
             <div className="card-body overflow-auto">
-              <p>This is a test modal.</p>
-              <p>It appears in the center of the preview area on the right side.</p>
-              <p>The background is darkened to emphasize the modal.</p>
-              <p className="mb-0">Click the close button or the background to close.</p>
+              <CustomTabContent
+                activeTab={activeTab}
+                navTabMapping={navTabMapping}
+              />
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>

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

@@ -0,0 +1,10 @@
+import React from 'react';
+
+export const DecorationTab: React.FC = () => {
+  return (
+    <div>
+      {/* TODO: 装飾タブの内容を実装する */}
+      装飾ガイド(準備中)
+    </div>
+  );
+};

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

@@ -0,0 +1,10 @@
+import React from 'react';
+
+export const LayoutTab: React.FC = () => {
+  return (
+    <div>
+      {/* TODO: レイアウトタブの内容を実装する */}
+      レイアウトガイド(準備中)
+    </div>
+  );
+};

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

@@ -0,0 +1,217 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+interface TextStyleGuideItem {
+  id: string;
+  title: string;
+  code: string;
+  preview: React.ReactNode;
+}
+
+export const ExternalLinkIcon = () => {
+  return (
+    <span
+      className="material-symbols-outlined align-middle ms-1 text-muted"
+      style={{
+        fontSize: '16px',
+      }}
+    >
+      open_in_new
+    </span>
+  );
+};
+
+type GuideRowProps = Omit<TextStyleGuideItem, 'id'>;
+
+const GuideRow = ({
+  title,
+  code,
+  preview,
+}: GuideRowProps) => {
+  const { t } = useTranslation();
+  const handleCopy = async () => {
+    await navigator.clipboard.writeText(code);
+    alert(t('editor_guide.textstyle.copy_done'));
+  };
+
+  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">
+        <div onClick={handleCopy} style={{ cursor: 'pointer' }} className="flex-shrink-0">
+          <div
+            className="bg-dark text-light p-2 ps-2 pe-4 rounded position-relative"
+            style={{
+              backgroundColor: '#2D2E32',
+              width: 'fit-content',
+            }}
+          >
+            <pre
+              className="m-0 small font-monospace"
+              style={{
+                whiteSpace: 'pre',
+                color: '#ABB2BF',
+                fontWeight: 400,
+                fontSize: '14px',
+              }}
+            >
+              {code}
+            </pre>
+            <small className="position-absolute badge bg-secondary opacity-50" style={{ fontSize: '0.4rem', top: '2px', right: '4px' }}>
+              Click
+            </small>
+          </div>
+        </div>
+        <div className="flex-grow-1" style={{ whiteSpace: 'nowrap' }}>
+          <div
+            className="wiki-content"
+            style={{
+              fontWeight: 400,
+              fontSize: '14px',
+            }}
+          >
+            {preview}
+          </div>
+        </div>
+      </div>
+    </section>
+  );
+};
+
+
+export const TextStyleTab: React.FC = () => {
+  const { t } = useTranslation();
+  const i18nKey = 'editor_guide.textstyle';
+
+  const TEXT_STYLE_GUIDES: TextStyleGuideItem[] = [
+    {
+      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">
+          <code
+            className="rounded px-1"
+            style={{
+              width: 'fit-content',
+              color: '#D63384',
+              border: '1px solid #D63384',
+              backgroundColor: 'transparent',
+            }}
+          >
+            {t(`${i18nKey}.inline_code`)}
+          </code>
+          <code
+            className="rounded px-1"
+            style={{
+              width: 'fit-content',
+              color: '#D63384',
+              border: '1px solid #D63384',
+              backgroundColor: 'transparent',
+            }}
+          >
+            {t(`${i18nKey}.inline_code`)}
+          </code>
+        </div>
+      ),
+    },
+    {
+      id: 'bold-italic',
+      title: t(`${i18nKey}.bold_italic`),
+      code: `***${t(`${i18nKey}.all_important`)}***`,
+      preview: <strong><u>{t(`${i18nKey}.all_important`).replace('\n', '')}</u></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`)}](https://docs.growi.org/ja/g)`,
+      preview: (
+        <a
+          href="https://docs.growi.org/ja/g"
+          target="_blank"
+          rel="noreferrer"
+          className="text-secondary text-decoration-underline"
+          onClick={e => e.stopPropagation()}
+        >
+          {t(`${i18nKey}.link_growi`)}
+          <ExternalLinkIcon />
+        </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`)}
+          <ExternalLinkIcon />
+        </a>
+      ),
+    },
+  ];
+  return (
+    <div className="px-4 py-2 overflow-y-auto" style={{ maxHeight: '80vh' }}>
+      {TEXT_STYLE_GUIDES.map(item => (
+        <GuideRow
+          key={item.id}
+          title={item.title}
+          code={item.code}
+          preview={item.preview}
+        />
+      ))}
+    </div>
+  );
+};