EmojiAutoCompleteHelper.js 6.1 KB

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