Last active
April 28, 2025 07:58
-
-
Save andreasvirkus/4dae8ef4e798e1389bc178fda725549c to your computer and use it in GitHub Desktop.
Revisions
-
andreasvirkus revised this gist
Jan 20, 2022 . 1 changed file with 0 additions and 11 deletions.There are no files selected for viewing
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 charactersOriginal 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' export const EmojiSearch = Mention.extend({ name: 'emoji-search', addOptions() { -
andreasvirkus revised this gist
Jan 20, 2022 . 1 changed file with 0 additions and 2 deletions.There are no files selected for viewing
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 charactersOriginal 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 { EmojiSearch, Klausmoji, insertHTML, getEmojis } from './utils/editorPlugins' import EmojiIcon from './assets/smile.svg' -
andreasvirkus created this gist
Jan 20, 2022 .There are no files selected for viewing
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 charactersOriginal 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} `) 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 charactersOriginal 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 }