Skip to content

Instantly share code, notes, and snippets.

@andreasvirkus
Last active April 28, 2025 07:58
Show Gist options
  • Save andreasvirkus/4dae8ef4e798e1389bc178fda725549c to your computer and use it in GitHub Desktop.
Save andreasvirkus/4dae8ef4e798e1389bc178fda725549c to your computer and use it in GitHub Desktop.

Revisions

  1. andreasvirkus revised this gist Jan 20, 2022. 1 changed file with 0 additions and 11 deletions.
    11 changes: 0 additions & 11 deletions editorPlugins.ts
    Original file line number Diff line number Diff line change
    @@ -8,17 +8,6 @@ import emojiData from 'emoji-mart-vue-fast/data/all.json'

    import { klausmojis } from './emoji'

    declare module '@tiptap/core' {
    interface Commands<ReturnType> {
    mention: {
    /**
    * Set mention label and id
    */
    setMention: (options: { label: string; id: string }) => ReturnType
    }
    }
    }

    export const EmojiSearch = Mention.extend({
    name: 'emoji-search',
    addOptions() {
  2. andreasvirkus revised this gist Jan 20, 2022. 1 changed file with 0 additions and 2 deletions.
    2 changes: 0 additions & 2 deletions Editor.vue
    Original file line number Diff line number Diff line change
    @@ -53,8 +53,6 @@ import { Extension } from '@tiptap/core'
    import StarterKit from '@tiptap/starter-kit'
    import 'emoji-mart-vue-fast/css/emoji-mart.css'
    import i18n from '@/i18n'
    import { EmojiSearch, Klausmoji, insertHTML, getEmojis } from './utils/editorPlugins'
    import EmojiIcon from './assets/smile.svg'
  3. andreasvirkus created this gist Jan 20, 2022.
    284 changes: 284 additions & 0 deletions Editor.vue
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,284 @@
    <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 i18n from '@/i18n'
    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>
    123 changes: 123 additions & 0 deletions editorPlugins.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,123 @@
    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'

    declare module '@tiptap/core' {
    interface Commands<ReturnType> {
    mention: {
    /**
    * Set mention label and id
    */
    setMention: (options: { label: string; id: string }) => ReturnType
    }
    }
    }

    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
    }