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

Merge pull request #8125 from weseek/feat/126520-emoji-features-1

feat: Implementation of picker for emoji input
Ryoji Shimizu 2 лет назад
Родитель
Сommit
f0bbc21cc8

+ 6 - 0
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -2,6 +2,7 @@ import React, {
   useCallback, useState, useRef, useEffect,
   useCallback, useState, useRef, useEffect,
 } from 'react';
 } from 'react';
 
 
+import { useResolvedThemeForEditor } from '@growi/editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -19,6 +20,7 @@ import {
 } from '~/stores/context';
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import { useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
+import { useNextThemes } from '~/stores/use-next-themes';
 
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
@@ -76,6 +78,10 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     increment: incrementEditingCommentsNum,
     increment: incrementEditingCommentsNum,
     decrement: decrementEditingCommentsNum,
     decrement: decrementEditingCommentsNum,
   } = useSWRxEditingCommentsNum();
   } = useSWRxEditingCommentsNum();
+  const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
+
+  const { resolvedTheme } = useNextThemes();
+  mutateResolvedTheme(resolvedTheme);
 
 
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [comment, setComment] = useState(commentBody ?? '');
   const [comment, setComment] = useState(commentBody ?? '');

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

@@ -8,7 +8,8 @@ import nodePath from 'path';
 import type { IPageHasId } from '@growi/core';
 import type { IPageHasId } from '@growi/core';
 import { pathUtils } from '@growi/core/dist/utils';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
 import {
-  CodeMirrorEditorMain, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated, AcceptedUploadFileType,
+  CodeMirrorEditorMain, GlobalCodeMirrorEditorKey, AcceptedUploadFileType,
+  useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
 } from '@growi/editor';
 } from '@growi/editor';
 import detectIndent from 'detect-indent';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -48,6 +49,7 @@ import {
   EditorMode,
   EditorMode,
   useEditorMode, useSelectedGrant,
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import { useNextThemes } from '~/stores/use-next-themes';
 import { useGlobalSocket } from '~/stores/websocket';
 import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -123,9 +125,13 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsConflict } = useIsConflict();
   const { mutate: mutateIsConflict } = useIsConflict();
 
 
+  const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
+
   const saveOrUpdate = useSaveOrUpdate();
   const saveOrUpdate = useSaveOrUpdate();
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
 
+  const { resolvedTheme } = useNextThemes();
+  mutateResolvedTheme(resolvedTheme);
 
 
   // TODO: remove workaround
   // TODO: remove workaround
   // for https://redmine.weseek.co.jp/issues/125923
   // for https://redmine.weseek.co.jp/issues/125923

+ 1 - 0
packages/editor/package.json

@@ -32,6 +32,7 @@
     "@uiw/react-codemirror": "^4.21.8",
     "@uiw/react-codemirror": "^4.21.8",
     "bootstrap": "^5.3.1",
     "bootstrap": "^5.3.1",
     "codemirror": "^6.0.1",
     "codemirror": "^6.0.1",
+    "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-react-refresh": "^0.4.1",
     "eslint-plugin-react-refresh": "^0.4.1",
     "react-dropzone": "^14.2.3",
     "react-dropzone": "^14.2.3",
     "react-hook-form": "^7.45.4",
     "react-hook-form": "^7.45.4",

+ 1 - 0
packages/editor/src/@types/emoji-mart.d.ts

@@ -0,0 +1 @@
+declare module 'emoji-mart';

+ 0 - 0
packages/editor/src/@types/declaration.d.ts → packages/editor/src/@types/scss.d.ts


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

@@ -105,7 +105,7 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
   return (
   return (
     <div {...getRootProps()} className="flex-expand-vert">
     <div {...getRootProps()} className="flex-expand-vert">
       <CodeMirrorEditorContainer ref={containerRef} />
       <CodeMirrorEditorContainer ref={containerRef} />
-      <Toolbar onFileOpen={open} acceptedFileType={acceptedFileType} />
+      <Toolbar editorKey={editorKey} onFileOpen={open} acceptedFileType={acceptedFileType} />
     </div>
     </div>
   );
   );
 };
 };

+ 146 - 4
packages/editor/src/components/CodeMirrorEditor/Toolbar/EmojiButton.tsx

@@ -1,7 +1,149 @@
-export const EmojiButton = (): JSX.Element => {
+import {
+  FC, useState, useCallback, CSSProperties,
+} from 'react';
+
+import { Picker } from 'emoji-mart';
+import i18n from 'i18next';
+import { Modal } from 'reactstrap';
+
+import { useCodeMirrorEditorIsolated, useResolvedThemeForEditor } from '../../../stores';
+
+import 'emoji-mart/css/emoji-mart.css';
+
+type Props = {
+  editorKey: string,
+}
+
+type Translation = {
+  search: string
+  clear: string
+  notfound: string
+  skintext: string
+  categories: object
+  categorieslabel: string
+  skintones: object
+  title: string
+}
+
+// TODO: https://redmine.weseek.co.jp/issues/133681
+const getEmojiTranslation = (): Translation => {
+
+  const categories: { [key: string]: string } = {};
+  [
+    'search',
+    'recent',
+    'smileys',
+    'people',
+    'nature',
+    'foods',
+    'activity',
+    'places',
+    'objects',
+    'symbols',
+    'flags',
+    'custom',
+  ].forEach((category) => {
+    categories[category] = i18n.t(`emoji.categories.${category}`);
+  });
+
+  const skintones: { [key: string]: string} = {};
+  (Array.from(Array(6).keys())).forEach((tone) => {
+    skintones[tone + 1] = i18n.t(`emoji.skintones.${tone + 1}`);
+  });
+
+  const translation = {
+    search: i18n.t('emoji.search'),
+    clear: i18n.t('emoji.clear'),
+    notfound: i18n.t('emoji.notfound'),
+    skintext: i18n.t('emoji.skintext'),
+    categories,
+    categorieslabel: i18n.t('emoji.categorieslabel'),
+    skintones,
+    title: i18n.t('emoji.title'),
+  };
+
+  return translation;
+};
+
+const translation = getEmojiTranslation();
+
+export const EmojiButton: FC<Props> = (props) => {
+  const { editorKey } = props;
+
+  const [isOpen, setIsOpen] = useState(false);
+
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
+  const { data: resolvedTheme } = useResolvedThemeForEditor();
+
+  const view = codeMirrorEditor?.view;
+  const cursorIndex = view?.state.selection.main.head;
+  const toggle = () => setIsOpen(!isOpen);
+
+  const selectEmoji = useCallback((emoji: { colons: string }): void => {
+
+    if (cursorIndex == null || !isOpen) {
+      return;
+    }
+
+    view?.dispatch({
+      changes: {
+        from: cursorIndex,
+        insert: emoji.colons,
+      },
+    });
+
+    toggle();
+  }, [cursorIndex, isOpen, toggle, view]);
+
+  const setStyle = useCallback((): CSSProperties => {
+    if (view == null || cursorIndex == null || !isOpen) {
+      return {};
+    }
+
+    const offset = 20;
+    const emojiPickerHeight = 420;
+    const cursorRect = view.coordsAtPos(cursorIndex);
+    const editorRect = view.dom.getBoundingClientRect();
+
+    if (cursorRect == null) {
+      return {};
+    }
+
+    // Emoji Picker bottom position exceed editor's bottom position
+    if (cursorRect.bottom + emojiPickerHeight > editorRect.bottom) {
+      return {
+        top: editorRect.bottom - emojiPickerHeight,
+        left: cursorRect.left + offset,
+        position: 'fixed',
+      };
+    }
+    return {
+      top: cursorRect.top + offset,
+      left: cursorRect.left + offset,
+      position: 'fixed',
+    };
+  }, [cursorIndex, isOpen, view]);
+
   return (
   return (
-    <button type="button" className="btn btn-toolbar-button">
-      <span className="material-symbols-outlined fs-5">emoji_emotions</span>
-    </button>
+    <>
+      <button type="button" className="btn btn-toolbar-button" onClick={toggle}>
+        <span className="material-symbols-outlined fs-5">emoji_emotions</span>
+      </button>
+      { isOpen
+      && (
+        <div className="mb-2 d-none d-md-block">
+          <Modal isOpen={isOpen} toggle={toggle} backdropClassName="emoji-picker-modal" fade={false}>
+            <Picker
+              onSelect={selectEmoji}
+              i18n={translation}
+              title={translation.title}
+              emojiTooltip
+              style={setStyle()}
+              theme={resolvedTheme}
+            />
+          </Modal>
+        </div>
+      )}
+    </>
   );
   );
 };
 };

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

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

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

@@ -1 +1,2 @@
 export * from './codemirror-editor';
 export * from './codemirror-editor';
+export * from './use-resolved-theme';

+ 27 - 0
packages/editor/src/stores/use-resolved-theme.ts

@@ -0,0 +1,27 @@
+import { useCallback } from 'react';
+
+import { ColorScheme } from '@growi/core';
+import { useSWRStatic } from '@growi/core/dist/swr';
+import type { SWRResponse } from 'swr';
+import { mutate } from 'swr';
+
+type ResolvedThemeStatus = {
+  themeData: ColorScheme,
+}
+
+type ResolvedThemeUtils = {
+  mutateResolvedThemeForEditor(resolvedTheme: ColorScheme): void
+}
+
+export const useResolvedThemeForEditor = (): SWRResponse<ResolvedThemeStatus, Error> & ResolvedThemeUtils => {
+  const swrResponse = useSWRStatic<ResolvedThemeStatus, Error>('resolvedTheme');
+
+  const mutateResolvedThemeForEditor = useCallback((resolvedTheme: ColorScheme) => {
+    mutate('resolvedTheme', { themeData: resolvedTheme });
+  }, []);
+
+  return {
+    ...swrResponse,
+    mutateResolvedThemeForEditor,
+  };
+};

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

@@ -50,6 +50,9 @@ export default defineConfig({
         preserveModules: true,
         preserveModules: true,
         preserveModulesRoot: 'src',
         preserveModulesRoot: 'src',
       },
       },
+      external: [
+        'emoji-mart/css/emoji-mart.css',
+      ],
     },
     },
   },
   },
 });
 });

+ 2 - 0
yarn.lock

@@ -1916,6 +1916,7 @@
   dependencies:
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/helper-plugin-utils" "^7.22.5"
 
 
+
 "@babel/plugin-transform-react-jsx-source@^7.22.5":
 "@babel/plugin-transform-react-jsx-source@^7.22.5":
   version "7.22.5"
   version "7.22.5"
   resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.22.5.tgz#49af1615bfdf6ed9d3e9e43e425e0b2b65d15b6c"
   resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.22.5.tgz#49af1615bfdf6ed9d3e9e43e425e0b2b65d15b6c"
@@ -1931,6 +1932,7 @@
     core-js-pure "^3.20.2"
     core-js-pure "^3.20.2"
     regenerator-runtime "^0.13.4"
     regenerator-runtime "^0.13.4"
 
 
+
 "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.6", "@babel/runtime@^7.19.4", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.6", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
 "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.6", "@babel/runtime@^7.19.4", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.6", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
   version "7.22.10"
   version "7.22.10"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.10.tgz#ae3e9631fd947cb7e3610d3e9d8fef5f76696682"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.10.tgz#ae3e9631fd947cb7e3610d3e9d8fef5f76696682"