logo

pleroma-fe

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

emoji_input.js (18948B)


  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.$tc('tool_tip.autocomplete_available',
  287. this.suggestions.length,
  288. { number: this.suggestions.length }))
  289. }
  290. },
  291. methods: {
  292. triggerShowPicker () {
  293. this.$nextTick(() => {
  294. this.$refs.picker.showPicker()
  295. this.scrollIntoView()
  296. })
  297. // This temporarily disables "click outside" handler
  298. // since external trigger also means click originates
  299. // from outside, thus preventing picker from opening
  300. this.disableClickOutside = true
  301. setTimeout(() => {
  302. this.disableClickOutside = false
  303. }, 0)
  304. },
  305. togglePicker () {
  306. this.input.focus()
  307. if (!this.pickerShown) {
  308. this.scrollIntoView()
  309. this.$refs.picker.showPicker()
  310. this.$refs.picker.startEmojiLoad()
  311. } else {
  312. this.$refs.picker.hidePicker()
  313. }
  314. },
  315. replace (replacement) {
  316. const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
  317. this.$emit('update:modelValue', newValue)
  318. this.caret = 0
  319. },
  320. insert ({ insertion, keepOpen, surroundingSpace = true }) {
  321. const before = this.modelValue.substring(0, this.caret) || ''
  322. const after = this.modelValue.substring(this.caret) || ''
  323. /* Using a bit more smart approach to padding emojis with spaces:
  324. * - put a space before cursor if there isn't one already, unless we
  325. * are at the beginning of post or in spam mode
  326. * - put a space after emoji if there isn't one already unless we are
  327. * in spam mode
  328. *
  329. * The idea is that when you put a cursor somewhere in between sentence
  330. * inserting just ' :emoji: ' will add more spaces to post which might
  331. * break the flow/spacing, as well as the case where user ends sentence
  332. * with a space before adding emoji.
  333. *
  334. * Spam mode is intended for creating multi-part emojis and overall spamming
  335. * them, masto seem to be rendering :emoji::emoji: correctly now so why not
  336. */
  337. const isSpaceRegex = /\s/
  338. const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
  339. const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
  340. const newValue = [
  341. before,
  342. spaceBefore,
  343. insertion,
  344. spaceAfter,
  345. after
  346. ].join('')
  347. this.$emit('update:modelValue', newValue)
  348. const position = this.caret + (insertion + spaceAfter + spaceBefore).length
  349. if (!keepOpen) {
  350. this.input.focus()
  351. }
  352. this.$nextTick(function () {
  353. // Re-focus inputbox after clicking suggestion
  354. // Set selection right after the replacement instead of the very end
  355. this.input.setSelectionRange(position, position)
  356. this.caret = position
  357. })
  358. },
  359. replaceText (e, suggestion) {
  360. const len = this.suggestions.length || 0
  361. if (this.textAtCaret.length === 1) { return }
  362. if (len > 0 || suggestion) {
  363. const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
  364. const replacement = chosenSuggestion.replacement
  365. const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
  366. this.$emit('update:modelValue', newValue)
  367. this.highlighted = 0
  368. const position = this.wordAtCaret.start + replacement.length
  369. this.$nextTick(function () {
  370. // Re-focus inputbox after clicking suggestion
  371. this.input.focus()
  372. // Set selection right after the replacement instead of the very end
  373. this.input.setSelectionRange(position, position)
  374. this.caret = position
  375. })
  376. e.preventDefault()
  377. }
  378. },
  379. cycleBackward (e) {
  380. const len = this.suggestions.length || 0
  381. this.highlighted -= 1
  382. if (this.highlighted === -1) {
  383. this.input.focus()
  384. } else if (this.highlighted < -1) {
  385. this.highlighted = len - 1
  386. }
  387. if (len > 0) {
  388. e.preventDefault()
  389. }
  390. },
  391. cycleForward (e) {
  392. const len = this.suggestions.length || 0
  393. this.highlighted += 1
  394. if (this.highlighted >= len) {
  395. this.highlighted = -1
  396. this.input.focus()
  397. }
  398. if (len > 0) {
  399. e.preventDefault()
  400. }
  401. },
  402. scrollIntoView () {
  403. const rootRef = this.$refs.picker.$el
  404. /* Scroller is either `window` (replies in TL), sidebar (main post form,
  405. * replies in notifs) or mobile post form. Note that getting and setting
  406. * scroll is different for `Window` and `Element`s
  407. */
  408. const scrollerRef = this.$el.closest('.sidebar-scroller') ||
  409. this.$el.closest('.post-form-modal-view') ||
  410. window
  411. const currentScroll = scrollerRef === window
  412. ? scrollerRef.scrollY
  413. : scrollerRef.scrollTop
  414. const scrollerHeight = scrollerRef === window
  415. ? scrollerRef.innerHeight
  416. : scrollerRef.offsetHeight
  417. const scrollerBottomBorder = currentScroll + scrollerHeight
  418. // We check where the bottom border of root element is, this uses findOffset
  419. // to find offset relative to scrollable container (scroller)
  420. const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
  421. const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
  422. // could also check top delta but there's no case for it
  423. const targetScroll = currentScroll + bottomDelta
  424. if (scrollerRef === window) {
  425. scrollerRef.scroll(0, targetScroll)
  426. } else {
  427. scrollerRef.scrollTop = targetScroll
  428. }
  429. this.$nextTick(() => {
  430. const { offsetHeight } = this.input
  431. const { picker } = this.$refs
  432. const pickerBottom = picker.$el.getBoundingClientRect().bottom
  433. if (pickerBottom > window.innerHeight) {
  434. picker.$el.style.top = 'auto'
  435. picker.$el.style.bottom = offsetHeight + 'px'
  436. }
  437. })
  438. },
  439. onPickerShown () {
  440. this.pickerShown = true
  441. },
  442. onPickerClosed () {
  443. this.pickerShown = false
  444. },
  445. onBlur (e) {
  446. // Clicking on any suggestion removes focus from autocomplete,
  447. // preventing click handler ever executing.
  448. this.blurTimeout = setTimeout(() => {
  449. this.focused = false
  450. this.setCaret(e)
  451. }, 200)
  452. },
  453. onClick (e, suggestion) {
  454. this.replaceText(e, suggestion)
  455. },
  456. onFocus (e) {
  457. if (this.blurTimeout) {
  458. clearTimeout(this.blurTimeout)
  459. this.blurTimeout = null
  460. }
  461. this.focused = true
  462. this.setCaret(e)
  463. this.temporarilyHideSuggestions = false
  464. },
  465. onKeyUp (e) {
  466. const { key } = e
  467. this.setCaret(e)
  468. // Setting hider in keyUp to prevent suggestions from blinking
  469. // when moving away from suggested spot
  470. if (key === 'Escape') {
  471. this.temporarilyHideSuggestions = true
  472. } else {
  473. this.temporarilyHideSuggestions = false
  474. }
  475. },
  476. onPaste (e) {
  477. this.setCaret(e)
  478. },
  479. onKeyDown (e) {
  480. const { ctrlKey, shiftKey, key } = e
  481. if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
  482. this.insert({ insertion: '\n', surroundingSpace: false })
  483. // Ensure only one new line is added on macos
  484. e.stopPropagation()
  485. e.preventDefault()
  486. // Scroll the input element to the position of the cursor
  487. this.$nextTick(() => {
  488. this.input.blur()
  489. this.input.focus()
  490. })
  491. }
  492. // Disable suggestions hotkeys if suggestions are hidden
  493. if (!this.temporarilyHideSuggestions) {
  494. if (key === 'Tab') {
  495. if (shiftKey) {
  496. this.cycleBackward(e)
  497. } else {
  498. this.cycleForward(e)
  499. }
  500. }
  501. if (key === 'ArrowUp') {
  502. this.cycleBackward(e)
  503. } else if (key === 'ArrowDown') {
  504. this.cycleForward(e)
  505. }
  506. if (key === 'Enter') {
  507. if (!ctrlKey) {
  508. this.replaceText(e)
  509. }
  510. }
  511. }
  512. // Probably add optional keyboard controls for emoji picker?
  513. // Escape hides suggestions, if suggestions are hidden it
  514. // de-focuses the element (i.e. default browser behavior)
  515. if (key === 'Escape') {
  516. if (!this.temporarilyHideSuggestions) {
  517. this.input.focus()
  518. }
  519. }
  520. },
  521. onInput (e) {
  522. this.setCaret(e)
  523. this.$emit('update:modelValue', e.target.value)
  524. },
  525. onStickerUploaded (e) {
  526. this.$emit('sticker-uploaded', e)
  527. },
  528. onStickerUploadFailed (e) {
  529. this.$emit('sticker-upload-Failed', e)
  530. },
  531. setCaret ({ target: { selectionStart } }) {
  532. this.caret = selectionStart
  533. this.$nextTick(() => {
  534. this.$refs.suggestorPopover.updateStyles()
  535. })
  536. },
  537. resize () {
  538. },
  539. autoCompleteItemLabel (suggestion) {
  540. if (suggestion.user) {
  541. return suggestion.displayText + ' ' + suggestion.detailText
  542. } else {
  543. return this.maybeLocalizedEmojiName(suggestion)
  544. }
  545. }
  546. }
  547. }
  548. export default EmojiInput