-
-
Notifications
You must be signed in to change notification settings - Fork 586
/
Copy pathsuggestion.ts
154 lines (131 loc) · 4.71 KB
/
suggestion.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import type { Emoji, EmojiMartData } from '@emoji-mart/data'
import type { SuggestionOptions } from '@tiptap/suggestion'
import type { mastodon } from 'masto'
import type { GetReferenceClientRect, Instance } from 'tippy.js'
import type { Component } from 'vue'
import { VueRenderer } from '@tiptap/vue-3'
import { PluginKey } from 'prosemirror-state'
import tippy from 'tippy.js'
import TiptapEmojiList from '~/components/tiptap/TiptapEmojiList.vue'
import TiptapHashtagList from '~/components/tiptap/TiptapHashtagList.vue'
import TiptapMentionList from '~/components/tiptap/TiptapMentionList.vue'
import { currentCustomEmojis, updateCustomEmojis } from '~/composables/emojis'
export type { Emoji }
export type CustomEmoji = (mastodon.v1.CustomEmoji & { custom: true })
export function isCustomEmoji(emoji: CustomEmoji | Emoji): emoji is CustomEmoji {
return !!(emoji as CustomEmoji).custom
}
export const TiptapMentionSuggestion: Partial<SuggestionOptions> = import.meta.server
? {}
: {
pluginKey: new PluginKey('mention'),
char: '@',
async items({ query }) {
if (query.length === 0)
return []
const paginator = useMastoClient().v2.search.list({ q: query, type: 'accounts', limit: 25, resolve: true })
return (await paginator.next()).value?.accounts ?? []
},
render: createSuggestionRenderer(TiptapMentionList),
}
export const TiptapHashtagSuggestion: Partial<SuggestionOptions> = {
pluginKey: new PluginKey('hashtag'),
char: '#',
async items({ query }) {
if (query.length === 0)
return []
const paginator = useMastoClient().v2.search.list({
q: query,
type: 'hashtags',
limit: 25,
resolve: false,
excludeUnreviewed: true,
})
return (await paginator.next()).value?.hashtags ?? []
},
render: createSuggestionRenderer(TiptapHashtagList),
}
export const TiptapEmojiSuggestion: Partial<SuggestionOptions> = {
pluginKey: new PluginKey('emoji'),
char: ':',
async items({ query }): Promise<(CustomEmoji | Emoji)[]> {
if (import.meta.server || query.length === 0)
return []
if (currentCustomEmojis.value.emojis.length === 0)
await updateCustomEmojis()
const lowerCaseQuery = query.toLowerCase()
const { data } = await useAsyncData<EmojiMartData>('emoji-data', () => import('@emoji-mart/data').then(r => r.default as EmojiMartData))
const emojis: Emoji[] = Object.values(data.value?.emojis || []).filter(({ id }) => id.toLowerCase().startsWith(lowerCaseQuery))
const customEmojis: CustomEmoji[] = currentCustomEmojis.value.emojis
.filter(emoji => emoji.shortcode.toLowerCase().startsWith(lowerCaseQuery))
.map(emoji => ({ ...emoji, custom: true }))
return [...emojis, ...customEmojis]
},
command: ({ editor, props, range }) => {
const emoji: CustomEmoji | Emoji = props.emoji
editor.commands.deleteRange(range)
if (isCustomEmoji(emoji)) {
editor.commands.insertCustomEmoji({
title: emoji.shortcode,
src: emoji.url,
})
}
else {
const skin = emoji.skins.find(skin => skin.native !== undefined)
if (skin)
editor.commands.insertEmoji(skin.native)
}
},
render: createSuggestionRenderer(TiptapEmojiList),
}
function createSuggestionRenderer(component: Component): SuggestionOptions['render'] {
return () => {
let renderer: VueRenderer
let popup: Instance
return {
onStart(props) {
renderer = new VueRenderer(component, {
props,
editor: props.editor,
})
if (!props.clientRect)
return
popup = tippy(document.body, {
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
appendTo: () => document.body,
content: renderer.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
// Use arrow function here because Nuxt will transform it incorrectly as Vue hook causing the build to fail
onBeforeUpdate: (props) => {
if (props.editor.isFocused)
renderer.updateProps({ ...props, isPending: true })
},
onUpdate(props) {
if (!props.editor.isFocused)
return
renderer.updateProps({ ...props, isPending: false })
if (!props.clientRect)
return
popup?.setProps({
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup?.hide()
return true
}
return renderer?.ref?.onKeyDown(props.event)
},
onExit() {
popup?.destroy()
renderer?.destroy()
},
}
}
}