Last active
April 27, 2024 20:27
-
-
Save andreasvirkus/4dae8ef4e798e1389bc178fda725549c to your computer and use it in GitHub Desktop.
Tiptap emoji plugin for Klausapp
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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} `) | |
this.$refs.emojis.tip.hide() | |
setTimeout(() => this.editor.commands.focus('end'), 0) | |
}, | |
}, | |
} | |
</script> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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