logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://anongit.hacktivis.me/git/pleroma-fe.git/

emoji_input.js (18974B)


  1. import Completion from '../../services/completion/completion.js'
  2. import genRandomSeed from '../../services/random_seed/random_seed.service.js'
  3. import EmojiPicker from '../emoji_picker/emoji_picker.vue'
  4. import Popover from 'src/components/popover/popover.vue'
  5. import ScreenReaderNotice from 'src/components/screen_reader_notice/screen_reader_notice.vue'
  6. import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
  7. import { take } from 'lodash'
  8. import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
  9. import { ensureFinalFallback } from '../../i18n/languages.js'
  10. import { library } from '@fortawesome/fontawesome-svg-core'
  11. import {
  12. faSmileBeam
  13. } from '@fortawesome/free-regular-svg-icons'
  14. library.add(
  15. faSmileBeam
  16. )
  17. /**
  18. * EmojiInput - augmented inputs for emoji and autocomplete support in inputs
  19. * without having to give up the comfort of <input/> and <textarea/> elements
  20. *
  21. * Intended usage is:
  22. * <EmojiInput v-model="something">
  23. * <input v-model="something"/>
  24. * </EmojiInput>
  25. *
  26. * Works only with <input> and <textarea>. Intended to use with only one nested
  27. * input. It will find first input or textarea and work with that, multiple
  28. * nested children not tested. You HAVE TO duplicate v-model for both
  29. * <emoji-input> and <input>/<textarea> otherwise it will not work.
  30. *
  31. * Be prepared for CSS troubles though because it still wraps component in a div
  32. * while TRYING to make it look like nothing happened, but it could break stuff.
  33. */
  34. const EmojiInput = {
  35. emits: ['update:modelValue', 'shown'],
  36. props: {
  37. suggest: {
  38. /**
  39. * suggest: function (input: String) => Suggestion[]
  40. *
  41. * Function that takes input string which takes string (textAtCaret)
  42. * and returns an array of Suggestions
  43. *
  44. * Suggestion is an object containing following properties:
  45. * displayText: string. Main display text, what actual suggestion
  46. * represents (user's screen name/emoji shortcode)
  47. * replacement: string. Text that should replace the textAtCaret
  48. * detailText: string, optional. Subtitle text, providing additional info
  49. * if present (user's nickname)
  50. * imageUrl: string, optional. Image to display alongside with suggestion,
  51. * currently if no image is provided, replacement will be used (for
  52. * unicode emojis)
  53. *
  54. * TODO: make it asynchronous when adding proper server-provided user
  55. * suggestions
  56. *
  57. * For commonly used suggestors (emoji, users, both) use suggestor.js
  58. */
  59. required: true,
  60. type: Function
  61. },
  62. modelValue: {
  63. /**
  64. * Used for v-model
  65. */
  66. required: true,
  67. type: String
  68. },
  69. enableEmojiPicker: {
  70. /**
  71. * Enables emoji picker support, this implies that custom emoji are supported
  72. */
  73. required: false,
  74. type: Boolean,
  75. default: false
  76. },
  77. hideEmojiButton: {
  78. /**
  79. * intended to use with external picker trigger, i.e. you have a button outside
  80. * input that will open up the picker, see triggerShowPicker()
  81. */
  82. required: false,
  83. type: Boolean,
  84. default: false
  85. },
  86. enableStickerPicker: {
  87. /**
  88. * Enables sticker picker support, only makes sense when enableEmojiPicker=true
  89. */
  90. required: false,
  91. type: Boolean,
  92. default: false
  93. },
  94. placement: {
  95. /**
  96. * Forces the panel to take a specific position relative to the input element.
  97. * The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
  98. */
  99. required: false,
  100. type: String, // 'auto', 'top', 'bottom'
  101. default: 'auto'
  102. },
  103. newlineOnCtrlEnter: {
  104. required: false,
  105. type: Boolean,
  106. default: false
  107. }
  108. },
  109. data () {
  110. return {
  111. randomSeed: genRandomSeed(),
  112. input: undefined,
  113. caretEl: undefined,
  114. highlighted: -1,
  115. caret: 0,
  116. focused: false,
  117. blurTimeout: null,
  118. temporarilyHideSuggestions: false,
  119. disableClickOutside: false,
  120. suggestions: [],
  121. overlayStyle: {},
  122. pickerShown: false
  123. }
  124. },
  125. components: {
  126. Popover,
  127. EmojiPicker,
  128. UnicodeDomainIndicator,
  129. ScreenReaderNotice
  130. },
  131. computed: {
  132. padEmoji () {
  133. return this.$store.getters.mergedConfig.padEmoji
  134. },
  135. defaultCandidateIndex () {
  136. return this.$store.getters.mergedConfig.autocompleteSelect ? 0 : -1
  137. },
  138. preText () {
  139. return this.modelValue.slice(0, this.caret)
  140. },
  141. postText () {
  142. return this.modelValue.slice(this.caret)
  143. },
  144. showSuggestions () {
  145. return this.focused &&
  146. this.suggestions &&
  147. this.suggestions.length > 0 &&
  148. !this.pickerShown &&
  149. !this.temporarilyHideSuggestions
  150. },
  151. textAtCaret () {
  152. return this.wordAtCaret?.word
  153. },
  154. wordAtCaret () {
  155. if (this.modelValue && this.caret) {
  156. const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
  157. return word
  158. }
  159. },
  160. languages () {
  161. return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
  162. },
  163. maybeLocalizedEmojiNamesAndKeywords () {
  164. return emoji => {
  165. const names = [emoji.displayText]
  166. const keywords = []
  167. if (emoji.displayTextI18n) {
  168. names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args))
  169. }
  170. if (emoji.annotations) {
  171. this.languages.forEach(lang => {
  172. names.push(emoji.annotations[lang]?.name)
  173. keywords.push(...(emoji.annotations[lang]?.keywords || []))
  174. })
  175. }
  176. return {
  177. names: names.filter(k => k),
  178. keywords: keywords.filter(k => k)
  179. }
  180. }
  181. },
  182. maybeLocalizedEmojiName () {
  183. return emoji => {
  184. if (!emoji.annotations) {
  185. return emoji.displayText
  186. }
  187. if (emoji.displayTextI18n) {
  188. return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
  189. }
  190. for (const lang of this.languages) {
  191. if (emoji.annotations[lang]?.name) {
  192. return emoji.annotations[lang].name
  193. }
  194. }
  195. return emoji.displayText
  196. }
  197. },
  198. onInputScroll () {
  199. this.$refs.hiddenOverlay.scrollTo({
  200. top: this.input.scrollTop,
  201. left: this.input.scrollLeft
  202. })
  203. },
  204. suggestionListId () {
  205. return `suggestions-${this.randomSeed}`
  206. },
  207. suggestionItemId () {
  208. return (index) => `suggestion-item-${index}-${this.randomSeed}`
  209. }
  210. },
  211. mounted () {
  212. const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs
  213. const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
  214. if (!input) return
  215. this.input = input
  216. this.caretEl = hiddenOverlayCaret
  217. if (suggestorPopover.setAnchorEl) {
  218. suggestorPopover.setAnchorEl(this.caretEl) // unit test compat
  219. this.$refs.picker.setAnchorEl(this.caretEl)
  220. } else {
  221. console.warn('setAnchorEl not found, are we in a unit test?')
  222. }
  223. const style = getComputedStyle(this.input)
  224. this.overlayStyle.padding = style.padding
  225. this.overlayStyle.border = style.border
  226. this.overlayStyle.margin = style.margin
  227. this.overlayStyle.lineHeight = style.lineHeight
  228. this.overlayStyle.fontFamily = style.fontFamily
  229. this.overlayStyle.fontSize = style.fontSize
  230. this.overlayStyle.wordWrap = style.wordWrap
  231. this.overlayStyle.whiteSpace = style.whiteSpace
  232. this.resize()
  233. input.addEventListener('blur', this.onBlur)
  234. input.addEventListener('focus', this.onFocus)
  235. input.addEventListener('paste', this.onPaste)
  236. input.addEventListener('keyup', this.onKeyUp)
  237. input.addEventListener('keydown', this.onKeyDown)
  238. input.addEventListener('click', this.onClickInput)
  239. input.addEventListener('transitionend', this.onTransition)
  240. input.addEventListener('input', this.onInput)
  241. input.addEventListener('scroll', this.onInputScroll)
  242. },
  243. unmounted () {
  244. const { input } = this
  245. if (input) {
  246. input.removeEventListener('blur', this.onBlur)
  247. input.removeEventListener('focus', this.onFocus)
  248. input.removeEventListener('paste', this.onPaste)
  249. input.removeEventListener('keyup', this.onKeyUp)
  250. input.removeEventListener('keydown', this.onKeyDown)
  251. input.removeEventListener('click', this.onClickInput)
  252. input.removeEventListener('transitionend', this.onTransition)
  253. input.removeEventListener('input', this.onInput)
  254. input.removeEventListener('scroll', this.onInputScroll)
  255. }
  256. },
  257. watch: {
  258. showSuggestions: function (newValue, oldValue) {
  259. this.$emit('shown', newValue)
  260. if (newValue) {
  261. this.$refs.suggestorPopover.showPopover()
  262. } else {
  263. this.$refs.suggestorPopover.hidePopover()
  264. }
  265. },
  266. textAtCaret: async function (newWord) {
  267. if (newWord === undefined) return
  268. const firstchar = newWord.charAt(0)
  269. if (newWord === firstchar) {
  270. this.suggestions = []
  271. return
  272. }
  273. const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords)
  274. // Async: cancel if textAtCaret has changed during wait
  275. if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) {
  276. this.suggestions = []
  277. return
  278. }
  279. this.suggestions = take(matchedSuggestions, 5)
  280. .map(({ imageUrl, ...rest }) => ({
  281. ...rest,
  282. img: imageUrl || ''
  283. }))
  284. this.highlighted = this.defaultCandidateIndex
  285. this.$refs.screenReaderNotice.announce(
  286. this.$t(
  287. 'tool_tip.autocomplete_available',
  288. { number: this.suggestions.length },
  289. this.suggestions.length
  290. )
  291. )
  292. }
  293. },
  294. methods: {
  295. triggerShowPicker () {
  296. this.$nextTick(() => {
  297. this.$refs.picker.showPicker()
  298. this.scrollIntoView()
  299. })
  300. // This temporarily disables "click outside" handler
  301. // since external trigger also means click originates
  302. // from outside, thus preventing picker from opening
  303. this.disableClickOutside = true
  304. setTimeout(() => {
  305. this.disableClickOutside = false
  306. }, 0)
  307. },
  308. togglePicker () {
  309. this.input.focus()
  310. if (!this.pickerShown) {
  311. this.scrollIntoView()
  312. this.$refs.picker.showPicker()
  313. this.$refs.picker.startEmojiLoad()
  314. } else {
  315. this.$refs.picker.hidePicker()
  316. }
  317. },
  318. replace (replacement) {
  319. const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
  320. this.$emit('update:modelValue', newValue)
  321. this.caret = 0
  322. },
  323. insert ({ insertion, keepOpen, surroundingSpace = true }) {
  324. const before = this.modelValue.substring(0, this.caret) || ''
  325. const after = this.modelValue.substring(this.caret) || ''
  326. /* Using a bit more smart approach to padding emojis with spaces:
  327. * - put a space before cursor if there isn't one already, unless we
  328. * are at the beginning of post or in spam mode
  329. * - put a space after emoji if there isn't one already unless we are
  330. * in spam mode
  331. *
  332. * The idea is that when you put a cursor somewhere in between sentence
  333. * inserting just ' :emoji: ' will add more spaces to post which might
  334. * break the flow/spacing, as well as the case where user ends sentence
  335. * with a space before adding emoji.
  336. *
  337. * Spam mode is intended for creating multi-part emojis and overall spamming
  338. * them, masto seem to be rendering :emoji::emoji: correctly now so why not
  339. */
  340. const isSpaceRegex = /\s/
  341. const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
  342. const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
  343. const newValue = [
  344. before,
  345. spaceBefore,
  346. insertion,
  347. spaceAfter,
  348. after
  349. ].join('')
  350. this.$emit('update:modelValue', newValue)
  351. const position = this.caret + (insertion + spaceAfter + spaceBefore).length
  352. if (!keepOpen) {
  353. this.input.focus()
  354. }
  355. this.$nextTick(function () {
  356. // Re-focus inputbox after clicking suggestion
  357. // Set selection right after the replacement instead of the very end
  358. this.input.setSelectionRange(position, position)
  359. this.caret = position
  360. })
  361. },
  362. replaceText (e, suggestion) {
  363. const len = this.suggestions.length || 0
  364. if (this.textAtCaret.length === 1) { return }
  365. if (len > 0 || suggestion) {
  366. const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
  367. const replacement = chosenSuggestion.replacement
  368. const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
  369. this.$emit('update:modelValue', newValue)
  370. this.highlighted = 0
  371. const position = this.wordAtCaret.start + replacement.length
  372. this.$nextTick(function () {
  373. // Re-focus inputbox after clicking suggestion
  374. this.input.focus()
  375. // Set selection right after the replacement instead of the very end
  376. this.input.setSelectionRange(position, position)
  377. this.caret = position
  378. })
  379. e.preventDefault()
  380. }
  381. },
  382. cycleBackward (e) {
  383. const len = this.suggestions.length || 0
  384. this.highlighted -= 1
  385. if (this.highlighted === -1) {
  386. this.input.focus()
  387. } else if (this.highlighted < -1) {
  388. this.highlighted = len - 1
  389. }
  390. if (len > 0) {
  391. e.preventDefault()
  392. }
  393. },
  394. cycleForward (e) {
  395. const len = this.suggestions.length || 0
  396. this.highlighted += 1
  397. if (this.highlighted >= len) {
  398. this.highlighted = -1
  399. this.input.focus()
  400. }
  401. if (len > 0) {
  402. e.preventDefault()
  403. }
  404. },
  405. scrollIntoView () {
  406. const rootRef = this.$refs.picker.$el
  407. /* Scroller is either `window` (replies in TL), sidebar (main post form,
  408. * replies in notifs) or mobile post form. Note that getting and setting
  409. * scroll is different for `Window` and `Element`s
  410. */
  411. const scrollerRef = this.$el.closest('.sidebar-scroller') ||
  412. this.$el.closest('.post-form-modal-view') ||
  413. window
  414. const currentScroll = scrollerRef === window
  415. ? scrollerRef.scrollY
  416. : scrollerRef.scrollTop
  417. const scrollerHeight = scrollerRef === window
  418. ? scrollerRef.innerHeight
  419. : scrollerRef.offsetHeight
  420. const scrollerBottomBorder = currentScroll + scrollerHeight
  421. // We check where the bottom border of root element is, this uses findOffset
  422. // to find offset relative to scrollable container (scroller)
  423. const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
  424. const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
  425. // could also check top delta but there's no case for it
  426. const targetScroll = currentScroll + bottomDelta
  427. if (scrollerRef === window) {
  428. scrollerRef.scroll(0, targetScroll)
  429. } else {
  430. scrollerRef.scrollTop = targetScroll
  431. }
  432. this.$nextTick(() => {
  433. const { offsetHeight } = this.input
  434. const { picker } = this.$refs
  435. const pickerBottom = picker.$el.getBoundingClientRect().bottom
  436. if (pickerBottom > window.innerHeight) {
  437. picker.$el.style.top = 'auto'
  438. picker.$el.style.bottom = offsetHeight + 'px'
  439. }
  440. })
  441. },
  442. onPickerShown () {
  443. this.pickerShown = true
  444. },
  445. onPickerClosed () {
  446. this.pickerShown = false
  447. },
  448. onBlur (e) {
  449. // Clicking on any suggestion removes focus from autocomplete,
  450. // preventing click handler ever executing.
  451. this.blurTimeout = setTimeout(() => {
  452. this.focused = false
  453. this.setCaret(e)
  454. }, 200)
  455. },
  456. onClick (e, suggestion) {
  457. this.replaceText(e, suggestion)
  458. },
  459. onFocus (e) {
  460. if (this.blurTimeout) {
  461. clearTimeout(this.blurTimeout)
  462. this.blurTimeout = null
  463. }
  464. this.focused = true
  465. this.setCaret(e)
  466. this.temporarilyHideSuggestions = false
  467. },
  468. onKeyUp (e) {
  469. const { key } = e
  470. this.setCaret(e)
  471. // Setting hider in keyUp to prevent suggestions from blinking
  472. // when moving away from suggested spot
  473. if (key === 'Escape') {
  474. this.temporarilyHideSuggestions = true
  475. } else {
  476. this.temporarilyHideSuggestions = false
  477. }
  478. },
  479. onPaste (e) {
  480. this.setCaret(e)
  481. },
  482. onKeyDown (e) {
  483. const { ctrlKey, shiftKey, key } = e
  484. if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
  485. this.insert({ insertion: '\n', surroundingSpace: false })
  486. // Ensure only one new line is added on macos
  487. e.stopPropagation()
  488. e.preventDefault()
  489. // Scroll the input element to the position of the cursor
  490. this.$nextTick(() => {
  491. this.input.blur()
  492. this.input.focus()
  493. })
  494. }
  495. // Disable suggestions hotkeys if suggestions are hidden
  496. if (!this.temporarilyHideSuggestions) {
  497. if (key === 'Tab') {
  498. if (shiftKey) {
  499. this.cycleBackward(e)
  500. } else {
  501. this.cycleForward(e)
  502. }
  503. }
  504. if (key === 'ArrowUp') {
  505. this.cycleBackward(e)
  506. } else if (key === 'ArrowDown') {
  507. this.cycleForward(e)
  508. }
  509. if (key === 'Enter') {
  510. if (!ctrlKey) {
  511. this.replaceText(e)
  512. }
  513. }
  514. }
  515. // Probably add optional keyboard controls for emoji picker?
  516. // Escape hides suggestions, if suggestions are hidden it
  517. // de-focuses the element (i.e. default browser behavior)
  518. if (key === 'Escape') {
  519. if (!this.temporarilyHideSuggestions) {
  520. this.input.focus()
  521. }
  522. }
  523. },
  524. onInput (e) {
  525. this.setCaret(e)
  526. this.$emit('update:modelValue', e.target.value)
  527. },
  528. onStickerUploaded (e) {
  529. this.$emit('sticker-uploaded', e)
  530. },
  531. onStickerUploadFailed (e) {
  532. this.$emit('sticker-upload-Failed', e)
  533. },
  534. setCaret ({ target: { selectionStart } }) {
  535. this.caret = selectionStart
  536. this.$nextTick(() => {
  537. this.$refs.suggestorPopover.updateStyles()
  538. })
  539. },
  540. resize () {
  541. },
  542. autoCompleteItemLabel (suggestion) {
  543. if (suggestion.user) {
  544. return suggestion.displayText + ' ' + suggestion.detailText
  545. } else {
  546. return this.maybeLocalizedEmojiName(suggestion)
  547. }
  548. }
  549. }
  550. }
  551. export default EmojiInput