EmojiAutoCompleteHelper.js 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. import UpdateDisplayUtil from '~/client/util/codemirror/update-display-util.ext';
  2. class EmojiAutoCompleteHelper {
  3. constructor(emojiStrategy) {
  4. this.emojiStrategy = emojiStrategy;
  5. this.emojiShortnameImageMap = {};
  6. this.initEmojiImageMap = this.initEmojiImageMap.bind(this);
  7. this.showHint = this.showHint.bind(this);
  8. this.initEmojiImageMap();
  9. }
  10. initEmojiImageMap() {
  11. for (const data of Object.values(this.emojiStrategy)) {
  12. const shortname = data.shortname;
  13. // add image tag
  14. this.emojiShortnameImageMap[shortname] = emojione.shortnameToImage(shortname);
  15. }
  16. }
  17. /**
  18. * try to find emoji terms and show hint
  19. * @param {any} editor An editor instance of CodeMirror
  20. */
  21. showHint(editor) {
  22. // see https://regex101.com/r/gy3i03/1
  23. const pattern = /:[^:\s]+/;
  24. const currentPos = editor.getCursor();
  25. // find previous ':shortname'
  26. const sc = editor.getSearchCursor(pattern, currentPos, { multiline: false });
  27. if (sc.findPrevious()) {
  28. const isInputtingEmoji = (currentPos.line === sc.to().line && currentPos.ch === sc.to().ch);
  29. // return if it isn't inputting emoji
  30. if (!isInputtingEmoji) {
  31. return;
  32. }
  33. }
  34. else {
  35. return;
  36. }
  37. /*
  38. * https://github.com/weseek/growi/issues/703 is caused
  39. * because 'editor.display.viewOffset' is zero
  40. *
  41. * call stack:
  42. * 1. https://github.com/codemirror/CodeMirror/blob/5.42.0/addon/hint/show-hint.js#L220
  43. * 2. https://github.com/codemirror/CodeMirror/blob/5.42.0/src/edit/methods.js#L189
  44. * 3. https://github.com/codemirror/CodeMirror/blob/5.42.0/src/measurement/position_measurement.js#L372
  45. * 4. https://github.com/codemirror/CodeMirror/blob/5.42.0/src/measurement/position_measurement.js#L315
  46. */
  47. UpdateDisplayUtil.forceUpdateViewOffset(editor);
  48. // see https://codemirror.net/doc/manual.html#addon_show-hint
  49. editor.showHint({
  50. completeSingle: false,
  51. // closeOnUnfocus: false, // for debug
  52. hint: () => {
  53. const matched = editor.getDoc().getRange(sc.from(), sc.to());
  54. const term = matched.replace(':', ''); // remove ':' in the head
  55. // get a list of shortnames
  56. const shortnames = this.searchEmojiShortnames(term);
  57. if (shortnames.length >= 1) {
  58. return {
  59. list: this.generateEmojiRenderer(shortnames),
  60. from: sc.from(),
  61. to: sc.to(),
  62. };
  63. }
  64. },
  65. });
  66. }
  67. /**
  68. * see https://codemirror.net/doc/manual.html#addon_show-hint
  69. * @param {string[]} emojiShortnames a list of shortname
  70. */
  71. generateEmojiRenderer(emojiShortnames) {
  72. return emojiShortnames.map((shortname) => {
  73. return {
  74. text: shortname,
  75. className: 'crowi-emoji-autocomplete',
  76. render: (element) => {
  77. element.innerHTML = `<div class="img-container">${this.emojiShortnameImageMap[shortname]}</div>`
  78. + `<span class="shortname-container">${shortname}</span>`;
  79. },
  80. };
  81. });
  82. }
  83. /**
  84. * transplanted from https://github.com/emojione/emojione/blob/master/examples/OTHER.md
  85. * @param {string} term
  86. * @returns {string[]} a list of shortname
  87. */
  88. searchEmojiShortnames(term) {
  89. const maxLength = 12;
  90. const results1 = [];
  91. const results2 = [];
  92. const results3 = [];
  93. const results4 = [];
  94. const countLen1 = () => { return results1.length };
  95. const countLen2 = () => { return countLen1() + results2.length };
  96. const countLen3 = () => { return countLen2() + results3.length };
  97. const countLen4 = () => { return countLen3() + results4.length };
  98. // TODO performance tune
  99. // when total length of all results is less than `maxLength`
  100. for (const data of Object.values(this.emojiStrategy)) {
  101. if (maxLength <= countLen1()) { break }
  102. // prefix match to shortname
  103. else if (data.shortname.indexOf(`:${term}`) > -1) {
  104. results1.push(data.shortname);
  105. continue;
  106. }
  107. else if (maxLength <= countLen2()) { continue }
  108. // partial match to shortname
  109. else if (data.shortname.indexOf(term) > -1) {
  110. results2.push(data.shortname);
  111. continue;
  112. }
  113. else if (maxLength <= countLen3()) { continue }
  114. // partial match to elements of aliases
  115. else if ((data.aliases != null) && data.aliases.find((elem) => { return elem.indexOf(term) > -1 })) {
  116. results3.push(data.shortname);
  117. continue;
  118. }
  119. else if (maxLength <= countLen4()) { continue }
  120. // partial match to elements of keywords
  121. else if ((data.keywords != null) && data.keywords.find((elem) => { return elem.indexOf(term) > -1 })) {
  122. results4.push(data.shortname);
  123. }
  124. }
  125. let results = results1.concat(results2).concat(results3).concat(results4);
  126. results = results.slice(0, maxLength);
  127. return results;
  128. }
  129. }
  130. export default EmojiAutoCompleteHelper;