forked from Mirrors/elk
feat(publish): add hashtag autocomplete (#778)
This commit is contained in:
parent
9d7b7b66ed
commit
1ff584bf8b
4 changed files with 94 additions and 20 deletions
68
components/tiptap/TiptapHashtagList.vue
Normal file
68
components/tiptap/TiptapHashtagList.vue
Normal file
|
@ -0,0 +1,68 @@
|
|||
<script setup lang="ts">
|
||||
import type { Tag } from 'masto'
|
||||
import CommonScrollIntoView from '../common/CommonScrollIntoView.vue'
|
||||
import HashtagInfo from '../search/HashtagInfo.vue'
|
||||
|
||||
const { items, command } = defineProps<{
|
||||
items: Tag[]
|
||||
command: Function
|
||||
isPending?: boolean
|
||||
}>()
|
||||
|
||||
let selectedIndex = $ref(0)
|
||||
|
||||
watch(items, () => {
|
||||
selectedIndex = 0
|
||||
})
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
selectedIndex = ((selectedIndex + items.length) - 1) % items.length
|
||||
return true
|
||||
}
|
||||
else if (event.key === 'ArrowDown') {
|
||||
selectedIndex = (selectedIndex + 1) % items.length
|
||||
return true
|
||||
}
|
||||
else if (event.key === 'Enter') {
|
||||
selectItem(selectedIndex)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function selectItem(index: number) {
|
||||
const item = items[index]
|
||||
if (item)
|
||||
command({ id: item.name })
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
onKeyDown,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isPending || items.length" relative bg-base text-base shadow border="~ base rounded" text-sm py-2 overflow-x-hidden overflow-y-auto max-h-100>
|
||||
<template v-if="isPending">
|
||||
<div flex gap-1 items-center p2 animate-pulse>
|
||||
<div i-ri:loader-2-line animate-spin />
|
||||
<span>Fetching...</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="items.length">
|
||||
<CommonScrollIntoView
|
||||
v-for="(item, index) in items" :key="index"
|
||||
:active="index === selectedIndex"
|
||||
as="button"
|
||||
:class="index === selectedIndex ? 'bg-active' : 'text-secondary'"
|
||||
block m0 w-full text-left px2 py1
|
||||
@click="selectItem(index)"
|
||||
>
|
||||
<HashtagInfo :hashtag="item" />
|
||||
</CommonScrollIntoView>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
|
@ -12,7 +12,7 @@ import Code from '@tiptap/extension-code'
|
|||
import { Plugin } from 'prosemirror-state'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { HashSuggestion, MentionSuggestion } from './tiptap/suggestion'
|
||||
import { HashtagSuggestion, MentionSuggestion } from './tiptap/suggestion'
|
||||
import { CodeBlockShiki } from './tiptap/shiki'
|
||||
import { CustomEmoji } from './tiptap/custom-emoji'
|
||||
import { Emoji } from './tiptap/emoji'
|
||||
|
@ -54,9 +54,9 @@ export function useTiptap(options: UseTiptapOptions) {
|
|||
suggestion: MentionSuggestion,
|
||||
}),
|
||||
Mention
|
||||
.extend({ name: 'hastag' })
|
||||
.extend({ name: 'hashtag' })
|
||||
.configure({
|
||||
suggestion: HashSuggestion,
|
||||
suggestion: HashtagSuggestion,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder.value,
|
||||
|
|
|
@ -3,7 +3,9 @@ import tippy from 'tippy.js'
|
|||
import { VueRenderer } from '@tiptap/vue-3'
|
||||
import type { SuggestionOptions } from '@tiptap/suggestion'
|
||||
import { PluginKey } from 'prosemirror-state'
|
||||
import type { Component } from 'vue'
|
||||
import TiptapMentionList from '~/components/tiptap/TiptapMentionList.vue'
|
||||
import TiptapHashtagList from '~/components/tiptap/TiptapHashtagList.vue'
|
||||
|
||||
export const MentionSuggestion: Partial<SuggestionOptions> = {
|
||||
pluginKey: new PluginKey('mention'),
|
||||
|
@ -17,29 +19,32 @@ export const MentionSuggestion: Partial<SuggestionOptions> = {
|
|||
|
||||
return results.value.accounts
|
||||
},
|
||||
render: createSuggestionRenderer(),
|
||||
render: createSuggestionRenderer(TiptapMentionList),
|
||||
}
|
||||
|
||||
export const HashSuggestion: Partial<SuggestionOptions> = {
|
||||
export const HashtagSuggestion: Partial<SuggestionOptions> = {
|
||||
pluginKey: new PluginKey('hashtag'),
|
||||
char: '#',
|
||||
items({ query }) {
|
||||
// TODO: query
|
||||
return [
|
||||
'TODO HASH QUERY',
|
||||
].filter(item => item.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5)
|
||||
async items({ query }) {
|
||||
if (query.length === 0)
|
||||
return []
|
||||
|
||||
const paginator = useMasto().search({ q: query, type: 'hashtags', limit: 25, resolve: true })
|
||||
const results = await paginator.next()
|
||||
|
||||
return results.value.hashtags
|
||||
},
|
||||
render: createSuggestionRenderer(),
|
||||
render: createSuggestionRenderer(TiptapHashtagList),
|
||||
}
|
||||
|
||||
function createSuggestionRenderer(): SuggestionOptions['render'] {
|
||||
function createSuggestionRenderer(component: Component): SuggestionOptions['render'] {
|
||||
return () => {
|
||||
let component: VueRenderer
|
||||
let renderer: VueRenderer
|
||||
let popup: Instance
|
||||
|
||||
return {
|
||||
onStart(props) {
|
||||
component = new VueRenderer(TiptapMentionList, {
|
||||
renderer = new VueRenderer(component, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
@ -50,7 +55,7 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
|
|||
popup = tippy(document.body, {
|
||||
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
content: renderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
|
@ -60,11 +65,11 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
|
|||
|
||||
// Use arrow function here because Nuxt will transform it incorrectly as Vue hook causing the build to fail
|
||||
onBeforeUpdate: (props) => {
|
||||
component.updateProps({ ...props, isPending: true })
|
||||
renderer.updateProps({ ...props, isPending: true })
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({ ...props, isPending: false })
|
||||
renderer.updateProps({ ...props, isPending: false })
|
||||
|
||||
if (!props.clientRect)
|
||||
return
|
||||
|
@ -79,12 +84,12 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
|
|||
popup?.hide()
|
||||
return true
|
||||
}
|
||||
return component?.ref?.onKeyDown(props.event)
|
||||
return renderer?.ref?.onKeyDown(props.event)
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup?.destroy()
|
||||
component?.destroy()
|
||||
renderer?.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
opacity: 0.4;
|
||||
}
|
||||
|
||||
span[data-type='mention'] {
|
||||
span[data-type='mention'],
|
||||
span[data-type='hashtag'] {
|
||||
--at-apply: text-primary;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue