Browse Source

Merge pull request #6562 from weseek/imprv/emoji-picker-performance

imprv: Emoji picker performance for apply-nextjs-2
Haku Mizuki 3 years ago
parent
commit
728b88c163

+ 101 - 29
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -7,6 +7,7 @@ import * as loadCssSync from 'load-css-file';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { Button } from 'reactstrap';
 import { Button } from 'reactstrap';
 import * as loadScript from 'simple-load-script';
 import * as loadScript from 'simple-load-script';
+import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 import InterceptorManager from '~/services/interceptor-manager';
 import InterceptorManager from '~/services/interceptor-manager';
@@ -106,7 +107,9 @@ class CodeMirrorEditor extends AbstractEditor {
       isCheatsheetModalShown: false,
       isCheatsheetModalShown: false,
       additionalClassSet: new Set(),
       additionalClassSet: new Set(),
       isEmojiPickerShown: false,
       isEmojiPickerShown: false,
-      emojiSearchText: null,
+      emojiSearchText: '',
+      startPosWithEmojiPickerModeTurnedOn: null,
+      isEmojiPickerMode: false,
     };
     };
 
 
     this.cm = React.createRef();
     this.cm = React.createRef();
@@ -132,7 +135,16 @@ class CodeMirrorEditor extends AbstractEditor {
     this.pasteHandler = this.pasteHandler.bind(this);
     this.pasteHandler = this.pasteHandler.bind(this);
     this.cursorHandler = this.cursorHandler.bind(this);
     this.cursorHandler = this.cursorHandler.bind(this);
     this.changeHandler = this.changeHandler.bind(this);
     this.changeHandler = this.changeHandler.bind(this);
-    this.keyUpHandler = this.keyUpHandler.bind(this);
+    this.turnOnEmojiPickerMode = this.turnOnEmojiPickerMode.bind(this);
+    this.turnOffEmojiPickerMode = this.turnOffEmojiPickerMode.bind(this);
+    this.windowClickHandler = this.windowClickHandler.bind(this);
+    this.keyDownHandler = this.keyDownHandler.bind(this);
+    this.keyDownHandlerForEmojiPicker = this.keyDownHandlerForEmojiPicker.bind(this);
+    this.keyDownHandlerForEmojiPickerThrottled = throttle(400, this.keyDownHandlerForEmojiPicker);
+    this.showEmojiPicker = this.showEmojiPicker.bind(this);
+    this.keyPressHandlerForEmojiPicker = this.keyPressHandlerForEmojiPicker.bind(this);
+    this.keyPressHandlerForEmojiPickerThrottled = debounce(50, throttle(200, this.keyPressHandlerForEmojiPicker));
+    this.keyPressHandler = this.keyPressHandler.bind(this);
 
 
     this.updateCheatsheetStates = this.updateCheatsheetStates.bind(this);
     this.updateCheatsheetStates = this.updateCheatsheetStates.bind(this);
 
 
@@ -146,7 +158,6 @@ class CodeMirrorEditor extends AbstractEditor {
 
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
-    this.checkWhetherEmojiPickerShouldBeShown = this.checkWhetherEmojiPickerShouldBeShown.bind(this);
 
 
   }
   }
 
 
@@ -180,6 +191,13 @@ class CodeMirrorEditor extends AbstractEditor {
     }
     }
     this.emojiPickerHelper = new EmojiPickerHelper(this.getCodeMirror());
     this.emojiPickerHelper = new EmojiPickerHelper(this.getCodeMirror());
 
 
+    // HACKME: Find a better way to handle onClick for Editor
+    document.addEventListener('click', this.windowClickHandler);
+  }
+
+  componentWillUnmount() {
+    // HACKME: Find a better way to handle onClick for Editor
+    document.removeEventListener('click', this.windowClickHandler);
   }
   }
 
 
   componentWillReceiveProps(nextProps) {
   componentWillReceiveProps(nextProps) {
@@ -583,10 +601,82 @@ class CodeMirrorEditor extends AbstractEditor {
 
 
   }
   }
 
 
-  keyUpHandler(editor, event) {
-    if (event.key !== 'Backspace') {
-      this.checkWhetherEmojiPickerShouldBeShown();
+  turnOnEmojiPickerMode(pos) {
+    this.setState({
+      isEmojiPickerMode: true,
+      startPosWithEmojiPickerModeTurnedOn: pos,
+    });
+  }
+
+  turnOffEmojiPickerMode() {
+    this.setState({
+      isEmojiPickerMode: false,
+    });
+  }
+
+  showEmojiPicker(initialSearchingText) {
+    // show emoji picker with a stored word
+    this.setState({
+      isEmojiPickerShown: true,
+      emojiSearchText: initialSearchingText ?? '',
+    });
+
+    const resetStartPos = initialSearchingText == null;
+    if (resetStartPos) {
+      this.setState({ startPosWithEmojiPickerModeTurnedOn: null });
     }
     }
+
+    this.turnOffEmojiPickerMode();
+  }
+
+  keyPressHandlerForEmojiPicker(editor, event) {
+    const char = event.key;
+    const isEmojiPickerMode = this.state.isEmojiPickerMode;
+
+    // evaluate whether emoji picker mode to be turned on
+    if (!isEmojiPickerMode) {
+      const startPos = this.emojiPickerHelper.shouldModeTurnOn(char);
+      if (startPos == null) {
+        return;
+      }
+
+      this.turnOnEmojiPickerMode(startPos);
+      return;
+    }
+
+    // evaluate whether EmojiPicker to be opened
+    const startPos = this.state.startPosWithEmojiPickerModeTurnedOn;
+    if (this.emojiPickerHelper.shouldOpen(startPos)) {
+      const initialSearchingText = this.emojiPickerHelper.getInitialSearchingText(startPos);
+      this.showEmojiPicker(initialSearchingText);
+      return;
+    }
+
+    this.turnOffEmojiPickerMode();
+  }
+
+  keyPressHandler(editor, event) {
+    this.keyPressHandlerForEmojiPickerThrottled(editor, event);
+  }
+
+  keyDownHandlerForEmojiPicker(editor, event) {
+    const key = event.key;
+
+    if (!this.state.isEmojiPickerMode) {
+      return;
+    }
+
+    if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'BackSpace'].includes(key)) {
+      this.turnOffEmojiPickerMode();
+    }
+  }
+
+  keyDownHandler(editor, event) {
+    this.keyDownHandlerForEmojiPickerThrottled(editor, event);
+  }
+
+  windowClickHandler() {
+    this.turnOffEmojiPickerMode();
   }
   }
 
 
   /**
   /**
@@ -610,26 +700,6 @@ class CodeMirrorEditor extends AbstractEditor {
 
 
   }
   }
 
 
-  /**
-   * Show emoji picker component when emoji pattern (`:` + searchWord ) found
-   * eg `:a`, `:ap`
-   */
-  checkWhetherEmojiPickerShouldBeShown() {
-    const searchWord = this.emojiPickerHelper.getEmoji();
-
-    if (searchWord == null) {
-      this.setState({ isEmojiPickerShown: false });
-      this.setState({ emojiSearchText: null });
-    }
-    else {
-      this.setState({ emojiSearchText: searchWord });
-      // Show emoji picker after user stop typing
-      setTimeout(() => {
-        this.setState({ isEmojiPickerShown: true });
-      }, 700);
-    }
-  }
-
   /**
   /**
    * update states which related to cheatsheet
    * update states which related to cheatsheet
    * @param {boolean} isGfmModeTmp (use state.isGfmMode if null is set)
    * @param {boolean} isGfmModeTmp (use state.isGfmMode if null is set)
@@ -709,7 +779,8 @@ class CodeMirrorEditor extends AbstractEditor {
         <div className="text-left">
         <div className="text-left">
           <div className="mb-2 d-none d-md-block">
           <div className="mb-2 d-none d-md-block">
             <EmojiPicker
             <EmojiPicker
-              onClose={() => this.setState({ isEmojiPickerShown: false, emojiSearchText: null })}
+              onClose={() => this.setState({ isEmojiPickerShown: false })}
+              onSelected={emoji => this.emojiPickerHelper.addEmoji(emoji, this.state.startPosWithEmojiPickerModeTurnedOn)}
               emojiSearchText={emojiSearchText}
               emojiSearchText={emojiSearchText}
               emojiPickerHelper={this.emojiPickerHelper}
               emojiPickerHelper={this.emojiPickerHelper}
               isOpen={this.state.isEmojiPickerShown}
               isOpen={this.state.isEmojiPickerShown}
@@ -959,7 +1030,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         color={null}
         bssize="small"
         bssize="small"
         title="Emoji"
         title="Emoji"
-        onClick={() => this.setState({ isEmojiPickerShown: true })}
+        onClick={() => this.showEmojiPicker()}
       >
       >
         <EditorIcon icon="Emoji" />
         <EditorIcon icon="Emoji" />
       </Button>,
       </Button>,
@@ -1039,7 +1110,8 @@ class CodeMirrorEditor extends AbstractEditor {
               this.props.onDragEnter(event);
               this.props.onDragEnter(event);
             }
             }
           }}
           }}
-          onKeyUp={this.keyUpHandler}
+          onKeyPress={this.keyPressHandler}
+          onKeyDown={this.keyDownHandler}
         />
         />
 
 
         { this.renderLoadingKeymapOverlay() }
         { this.renderLoadingKeymapOverlay() }

+ 1 - 1
packages/app/src/components/PageEditor/Editor.tsx

@@ -38,7 +38,7 @@ export type EditorPropsType = {
   onDragEnter?: (event: any) => void,
   onDragEnter?: (event: any) => void,
   onMarkdownHelpButtonClicked?: () => void,
   onMarkdownHelpButtonClicked?: () => void,
   onAddAttachmentButtonClicked?: () => void,
   onAddAttachmentButtonClicked?: () => void,
-  onScroll?: ({ line: number }) => void,
+  onScroll?: (line: { line: number }) => void,
   onScrollCursorIntoView?: (line: number) => void,
   onScrollCursorIntoView?: (line: number) => void,
   onSave?: () => Promise<void>,
   onSave?: () => Promise<void>,
   onPasteFiles?: (event: Event) => void,
   onPasteFiles?: (event: Event) => void,

+ 9 - 13
packages/app/src/components/PageEditor/EmojiPicker.tsx

@@ -1,4 +1,4 @@
-import React, { FC } from 'react';
+import React, { FC, useCallback } from 'react';
 
 
 import { Picker } from 'emoji-mart';
 import { Picker } from 'emoji-mart';
 import { Modal } from 'reactstrap';
 import { Modal } from 'reactstrap';
@@ -9,6 +9,7 @@ import EmojiPickerHelper, { getEmojiTranslation } from './EmojiPickerHelper';
 
 
 type Props = {
 type Props = {
   onClose: () => void,
   onClose: () => void,
+  onSelected: (emoji: string) => void,
   emojiSearchText: string,
   emojiSearchText: string,
   emojiPickerHelper: EmojiPickerHelper,
   emojiPickerHelper: EmojiPickerHelper,
   isOpen: boolean
   isOpen: boolean
@@ -17,30 +18,25 @@ type Props = {
 const EmojiPicker: FC<Props> = (props: Props) => {
 const EmojiPicker: FC<Props> = (props: Props) => {
 
 
   const {
   const {
-    onClose, emojiSearchText, emojiPickerHelper, isOpen,
+    onClose, onSelected, emojiSearchText, emojiPickerHelper, isOpen,
   } = props;
   } = props;
 
 
   const { resolvedTheme } = useNextThemes();
   const { resolvedTheme } = useNextThemes();
 
 
   // Set search emoji input and trigger search
   // Set search emoji input and trigger search
-  const searchEmoji = () => {
+  const searchEmoji = useCallback(() => {
     const input = window.document.querySelector('[id^="emoji-mart-search"]') as HTMLInputElement;
     const input = window.document.querySelector('[id^="emoji-mart-search"]') as HTMLInputElement;
     const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
     const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
     valueSetter?.call(input, emojiSearchText);
     valueSetter?.call(input, emojiSearchText);
     const event = new Event('input', { bubbles: true });
     const event = new Event('input', { bubbles: true });
     input.dispatchEvent(event);
     input.dispatchEvent(event);
     input.focus();
     input.focus();
-  };
-
-  const selectEmoji = (emoji) => {
-    if (emojiSearchText !== null) {
-      emojiPickerHelper.addEmojiOnSearch(emoji);
-    }
-    else {
-      emojiPickerHelper.addEmoji(emoji);
-    }
+  }, [emojiSearchText]);
+
+  const selectEmoji = useCallback((emoji) => {
+    onSelected(emoji);
     onClose();
     onClose();
-  };
+  }, [onClose, onSelected]);
 
 
 
 
   const translation = getEmojiTranslation();
   const translation = getEmojiTranslation();

+ 28 - 39
packages/app/src/components/PageEditor/EmojiPickerHelper.ts

@@ -1,22 +1,22 @@
 import { CSSProperties } from 'react';
 import { CSSProperties } from 'react';
 
 
+import { Position } from 'codemirror';
 import i18n from 'i18next';
 import i18n from 'i18next';
 
 
-// https://regex101.com/r/Gqhor8/1
-const EMOJI_PATTERN = new RegExp(/\B:[^:\s]+/);
+// https://regex101.com/r/x5LbOZ/1
+const EMOJI_PATTERN = new RegExp(/^:[a-z0-9-+_]+$/);
 
 
 export default class EmojiPickerHelper {
 export default class EmojiPickerHelper {
 
 
   editor;
   editor;
 
 
-  pattern: RegExp;
+  pattern: string;
 
 
   constructor(editor) {
   constructor(editor) {
     this.editor = editor;
     this.editor = editor;
-    this.pattern = EMOJI_PATTERN;
   }
   }
 
 
-  setStyle = ():CSSProperties => {
+  setStyle = (): CSSProperties => {
     const offset = 20;
     const offset = 20;
     const emojiPickerHeight = 420;
     const emojiPickerHeight = 420;
     const cursorPos = this.editor.cursorCoords(true);
     const cursorPos = this.editor.cursorCoords(true);
@@ -36,53 +36,42 @@ export default class EmojiPickerHelper {
     };
     };
   };
   };
 
 
-  getSearchCursor = () => {
-    const currentPos = this.editor.getCursor();
-    const sc = this.editor.getSearchCursor(this.pattern, currentPos, { multiline: false });
-    return sc;
-  };
+  shouldModeTurnOn = (char: string): Position | null | undefined => {
+    if (char !== ':') {
+      return null;
+    }
 
 
-  // Add emoji when triggered by search
-  addEmojiOnSearch = (emoji) => {
     const currentPos = this.editor.getCursor();
     const currentPos = this.editor.getCursor();
-    const sc = this.getSearchCursor();
+    const sc = this.editor.getSearchCursor(':', currentPos, { multiline: false });
     if (sc.findPrevious()) {
     if (sc.findPrevious()) {
-      sc.replace(`${emoji.colons} `, this.editor.getTokenAt(currentPos).string);
-      this.editor.focus();
-      this.editor.refresh();
+      return sc.pos.from;
     }
     }
   };
   };
 
 
+  shouldOpen = (startPos: Position): boolean => {
+    const currentPos = this.editor.getCursor();
+    const rangeStr = this.editor.getRange(startPos, currentPos);
+
+    return EMOJI_PATTERN.test(rangeStr);
+  };
 
 
-  // Add emoji when triggered by click emoji icon on top of editor
-  addEmoji = (emoji) => {
+  getInitialSearchingText = (startPos: Position): void => {
     const currentPos = this.editor.getCursor();
     const currentPos = this.editor.getCursor();
-    const doc = this.editor.getDoc();
-    doc.replaceRange(`${emoji.colons} `, currentPos);
-    this.editor.focus();
-    this.editor.refresh();
+    const rangeStr = this.editor.getRange(startPos, currentPos);
+
+    return rangeStr.slice(1); // return without the heading ':'
   };
   };
 
 
-  getEmoji = () => {
-    const sc = this.getSearchCursor();
+  addEmoji = (emoji: { colons: string }, startPosToReplace: Position|null): void => {
     const currentPos = this.editor.getCursor();
     const currentPos = this.editor.getCursor();
 
 
-    if (sc.findPrevious()) {
-      const isInputtingEmoji = (currentPos.line === sc.to().line && currentPos.ch === sc.to().ch);
-      // current search cursor position
-      if (!isInputtingEmoji) {
-        return;
-      }
-      const pos = {
-        line: sc.to().line,
-        ch: sc.to().ch,
-      };
-      const currentSearchText = sc.matches(true, pos).match[0];
-      const searchWord = currentSearchText.replace(':', '');
-      return searchWord;
-    }
+    const from = startPosToReplace ?? currentPos;
+    const to = currentPos;
 
 
-    return;
+    const doc = this.editor.getDoc();
+    doc.replaceRange(`${emoji.colons} `, from, to);
+    this.editor.focus();
+    this.editor.refresh();
   };
   };
 
 
 }
 }