Skip to content

Instantly share code, notes, and snippets.

@andreasvirkus
Last active April 27, 2024 20:27
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save andreasvirkus/4dae8ef4e798e1389bc178fda725549c to your computer and use it in GitHub Desktop.
Save andreasvirkus/4dae8ef4e798e1389bc178fda725549c to your computer and use it in GitHub Desktop.
Tiptap emoji plugin for Klausapp
<template>
<div ref="editor" class="notranslate">
<div v-show="showSuggestions" ref="suggestions">
<template v-if="(filteredSuggestions || []).length">
<div
v-for="(item, index) in filteredSuggestions.slice(0, 10)"
:key="index"
:class="[$style.suggestion, navigatedSuggestionIndex === index && $style.selected]"
@click="selectSuggestion(item)"
>
<template v-if="suggestionType === 'emoji'">
<template v-if="item.native">{{ item.native }}</template>
<img v-else :src="item.imageUrl" :class="$style.customEmoji" />
<span class="m-left-xs">{{ item.colons }}</span>
</template>
</div>
</template>
<div v-else :class="[$style.suggestion, $style.empty]">{{ noSuggestionsMessage }}</div>
</div>
<editor-content dir="auto" :editor="editor" :class="$style.editor" />
<div :class="$style.footer">
<tippy ref="emojis" trigger="click" placement="top-end" interactive :arrow="false" theme="light">
<template #trigger>
<button :class="[$style.button, emojiPickerVisible() && $style.active]" type="button">
<emoji-icon class="icon-s" />
</button>
</template>
<picker
native
emoji-tooltip
auto-focus
color="#475DE5"
title="Pick your favorite"
emoji="starklaus"
:data="emojiIndex"
:class="$style.emojiPicker"
@select="selectEmoji"
/>
</tippy>
</div>
</div>
</template>
<script>
import tippy from 'tippy.js'
import { TippyComponent } from 'vue-tippy'
import { isEqual, debounce } from 'lodash-es'
import { Picker } from 'emoji-mart-vue-fast'
import { Editor, EditorContent } from '@tiptap/vue-2'
import { Extension } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import 'emoji-mart-vue-fast/css/emoji-mart.css'
import { EmojiSearch, Klausmoji, insertHTML, getEmojis } from './utils/editorPlugins'
import EmojiIcon from './assets/smile.svg'
export default {
name: 'CommentEditor',
components: {
EditorContent,
Picker,
Tippy: TippyComponent,
EmojiIcon,
},
props: {
value: String,
},
data() {
return {
filteredSuggestions: [],
suggestionType: '',
suggestionQuery: null,
suggestionRange: null,
suggestionPopup: null,
navigatedSuggestionIndex: 0,
insertSuggestion: () => undefined,
val: this.value || '',
suggestions: [],
emojiIndex: getEmojis(),
editor: new Editor({
extensions: [
Klausmoji.configure({ HTMLAttributes: { class: 'klausmoji', style: 'vertical-align: text-bottom;' } }),
EmojiSearch.configure({
mentionClass: 'emoji',
suggestion: {
char: ':',
items: ({ query: q }) => {
if (!q) {
this.destroySuggestionPopup()
return []
}
return this.emojiIndex.search(q)
},
render: () => ({
onStart: (args) => {
this.suggestionType = 'emoji'
this.onStartSuggestions(args)
},
onUpdate: this.onUpdateSuggestions,
onExit: async () => {
await this.$nextTick()
this.resetMentions()
},
onKeyDown: this.onKeyDownHandler,
}),
},
}),
],
content: this.value || '',
onUpdate: () => {
this.setValues()
},
}),
}
},
computed: {
showSuggestions() {
if (this.suggestionType !== 'emoji') return false
return !!this.suggestionQuery || (this.filteredSuggestions || []).length
},
noSuggestionsMessage() {
return this.$t('conversations.sidebar.no_items_found')
},
},
beforeDestroy() {
this.editor.destroy()
this.suggestions = []
},
methods: {
focus() {
this.editor.commands.focus()
},
setValues() {
const html = this.editor.getHTML()
this.val = html === '<p></p>' ? '' : html
this.suggestions = this.getSuggestions(this.editor.getJSON())
},
getSuggestions(obj) {
const array = Array.isArray(obj) ? obj : [obj]
return array
.filter(({ type }) => type !== 'codeBlock') // Disable suggestions inside code block
.filter(({ marks }) => !(marks && marks.some(({ type }) => type === 'code'))) // Disable suggestions inside code
.reduce((suggestion, value) => {
if (value.content) {
suggestion = suggestion.concat(this.getSuggestions(value.content))
}
return suggestion
}, [])
},
// navigate to the previous item
// if it's the first item, navigate to the last one
upSuggestionHandler() {
this.navigatedSuggestionIndex =
(this.navigatedSuggestionIndex + this.filteredSuggestions.length - 1) % this.filteredSuggestions.length
},
// navigate to the next item
// if it's the last item, navigate to the first one
downSuggestionHandler() {
this.navigatedSuggestionIndex = (this.navigatedSuggestionIndex + 1) % this.filteredSuggestions.length
},
enterSuggestionHandler() {
const item = this.filteredSuggestions[this.navigatedSuggestionIndex]
if (item) this.selectSuggestion(item)
},
onKeyDownHandler({ event }) {
// pressing up arrow
if (event.key === 'ArrowUp') {
this.upSuggestionHandler()
return true
}
// pressing down arrow
if (event.key === 'ArrowDown') {
this.downSuggestionHandler()
return true
}
// pressing enter
if (event.key === 'Enter') {
this.enterSuggestionHandler()
tippy.hideAll()
return true
}
if (event.key === 'Space') {
// Check if there's a new tag leading up to the cursor
this.enterSuggestionHandler()
tippy.hideAll()
return true
}
if (event.key === 'Escape') {
tippy.hideAll()
this.resetMentions()
return true
}
return false
},
// we have to replace our suggestion text with a mention
// so it's important to pass also the position of your suggestion text
async selectSuggestion(item) {
// TODO: This can be replaced with this.editor.commands.mention()
// That way we don't need to store insertSuggestion in the suggestion
// start handler
if (this.suggestionType === 'emoji' && item.custom) {
this.selectEmoji(item)
this.destroySuggestionPopup()
} else if (typeof item !== 'string' && 'name' in item && !item.name) {
this.destroySuggestionPopup()
return this.focus()
}
const label = item.custom ? '' : item.native || item.name || item.replace('#', '')
this.insertSuggestion({
id: item.id || null,
label,
})
this.focus()
},
renderSuggestionPopup() {
if (!this.showSuggestions) return
if (this.suggestionPopup) {
this.suggestionPopup.popperInstance.update()
return
}
this.suggestionPopup = tippy(this.$el, {
content: this.$refs.suggestions,
trigger: 'mouseenter',
interactive: true,
theme: 'light left-align',
placement: 'top-start',
allowHTML: true,
inertia: true,
duration: [400, 200],
maxWidth: 400,
showOnInit: true,
sticky: true,
arrow: false,
animateFill: false,
})
},
destroySuggestionPopup() {
if (this.suggestionPopup && 'destroy' in this.suggestionPopup) this.suggestionPopup.destroy()
this.suggestionPopup = null
},
onStartSuggestions({ items, query, range, command }) {
this.suggestionQuery = query
this.filteredSuggestions = items
this.suggestionRange = range
this.renderSuggestionPopup()
this.insertSuggestion = command
},
onUpdateSuggestions({ items, query, range, command }) {
this.suggestionQuery = query
this.filteredSuggestions = items
this.suggestionRange = range
this.navigatedSuggestionIndex = 0
this.renderSuggestionPopup()
this.insertSuggestion = command
},
resetMentions() {
this.suggestionType = ''
this.suggestionQuery = null
this.filteredSuggestions = []
this.suggestionRange = null
this.navigatedSuggestionIndex = 0
this.destroySuggestionPopup()
},
emojiPickerVisible() {
return this.$refs.emojis?.tip.state.isVisible
},
selectEmoji(emj) {
if (emj.custom) this.editor.commands.setKlausmoji({ src: emj.imageUrl })
// TODO: Insert whitespace after native emoji
else insertHTML(this.editor, `${emj.native}&nbsp;`)
this.$refs.emojis.tip.hide()
setTimeout(() => this.editor.commands.focus('end'), 0)
},
},
}
</script>
import Mention from '@tiptap/extension-mention'
import Image from '@tiptap/extension-image'
import { DOMParser } from 'prosemirror-model'
import { PluginKey } from 'prosemirror-state'
import { mergeAttributes, textblockTypeInputRule } from '@tiptap/core'
import { EmojiIndex } from 'emoji-mart-vue-fast'
import emojiData from 'emoji-mart-vue-fast/data/all.json'
import { klausmojis } from './emoji'
export const EmojiSearch = Mention.extend({
name: 'emoji-search',
addOptions() {
return {
...this.parent?.(),
suggestion: {
pluginKey: new PluginKey('emojisearch'),
command: ({ editor, range, props }) => {
editor
.chain()
.focus()
.insertContentAt(range, [
{ type: 'emoji-search', attrs: props },
{ type: 'text', text: ' ' },
])
.run()
},
},
}
},
addAttributes() {
return {
label: { default: null },
}
},
renderHTML({ node, HTMLAttributes }) {
return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), `${node.attrs.label}`]
},
renderText({ node }) {
return `${node.attrs.label}`
},
addKeyboardShortcuts() {
return {}
},
})
declare module '@tiptap/core' {
interface Commands<ReturnType> {
klausmoji: {
/**
* Set emoji with url
*/
setKlausmoji: (options: { src: string }) => ReturnType
}
}
}
export const Klausmoji = Image.extend({
name: 'klausmoji',
inline: true,
group: 'inline',
draggable: false,
addAttributes() {
return {
src: {},
alt: { default: null },
title: { default: null },
height: { default: 18 },
width: { default: 18 },
}
},
parseHTML() {
return [{ tag: 'img.klausmoji[src]' }]
},
addCommands() {
return {
setKlausmoji:
(options) =>
({ tr, dispatch }) => {
const { selection } = tr
const node = this.type.create(options)
if (dispatch) tr.replaceRangeWith(selection.from, selection.to, node)
return true
},
}
},
})
const elementFromString = (value: string) => {
const element = document.createElement('span')
element.innerHTML = value.trim()
return element
}
export const insertHTML = ({ state, view }, value: string) => {
const { selection } = state
const element = elementFromString(value)
const slice = DOMParser.fromSchema(state.schema).parseSlice(element)
const transaction = state.tr.insert(selection.anchor, slice.content)
view.dispatch(transaction)
}
let emojiIndex
export const getEmojis = () => {
if (!emojiIndex) emojiIndex = new EmojiIndex(emojiData, { custom: klausmojis })
return emojiIndex
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment