feat(publish): add hashtag autocomplete (#778)

This commit is contained in:
Piotrek Tomczewski 2023-01-04 21:47:29 +01:00 committed by GitHub
parent 9d7b7b66ed
commit 1ff584bf8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 94 additions and 20 deletions

View 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>

View file

@ -12,7 +12,7 @@ import Code from '@tiptap/extension-code'
import { Plugin } from 'prosemirror-state' import { Plugin } from 'prosemirror-state'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { HashSuggestion, MentionSuggestion } from './tiptap/suggestion' import { HashtagSuggestion, MentionSuggestion } from './tiptap/suggestion'
import { CodeBlockShiki } from './tiptap/shiki' import { CodeBlockShiki } from './tiptap/shiki'
import { CustomEmoji } from './tiptap/custom-emoji' import { CustomEmoji } from './tiptap/custom-emoji'
import { Emoji } from './tiptap/emoji' import { Emoji } from './tiptap/emoji'
@ -54,9 +54,9 @@ export function useTiptap(options: UseTiptapOptions) {
suggestion: MentionSuggestion, suggestion: MentionSuggestion,
}), }),
Mention Mention
.extend({ name: 'hastag' }) .extend({ name: 'hashtag' })
.configure({ .configure({
suggestion: HashSuggestion, suggestion: HashtagSuggestion,
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: placeholder.value, placeholder: placeholder.value,

View file

@ -3,7 +3,9 @@ import tippy from 'tippy.js'
import { VueRenderer } from '@tiptap/vue-3' import { VueRenderer } from '@tiptap/vue-3'
import type { SuggestionOptions } from '@tiptap/suggestion' import type { SuggestionOptions } from '@tiptap/suggestion'
import { PluginKey } from 'prosemirror-state' import { PluginKey } from 'prosemirror-state'
import type { Component } from 'vue'
import TiptapMentionList from '~/components/tiptap/TiptapMentionList.vue' import TiptapMentionList from '~/components/tiptap/TiptapMentionList.vue'
import TiptapHashtagList from '~/components/tiptap/TiptapHashtagList.vue'
export const MentionSuggestion: Partial<SuggestionOptions> = { export const MentionSuggestion: Partial<SuggestionOptions> = {
pluginKey: new PluginKey('mention'), pluginKey: new PluginKey('mention'),
@ -17,29 +19,32 @@ export const MentionSuggestion: Partial<SuggestionOptions> = {
return results.value.accounts return results.value.accounts
}, },
render: createSuggestionRenderer(), render: createSuggestionRenderer(TiptapMentionList),
} }
export const HashSuggestion: Partial<SuggestionOptions> = { export const HashtagSuggestion: Partial<SuggestionOptions> = {
pluginKey: new PluginKey('hashtag'), pluginKey: new PluginKey('hashtag'),
char: '#', char: '#',
items({ query }) { async items({ query }) {
// TODO: query if (query.length === 0)
return [ return []
'TODO HASH QUERY',
].filter(item => item.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5) 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 () => { return () => {
let component: VueRenderer let renderer: VueRenderer
let popup: Instance let popup: Instance
return { return {
onStart(props) { onStart(props) {
component = new VueRenderer(TiptapMentionList, { renderer = new VueRenderer(component, {
props, props,
editor: props.editor, editor: props.editor,
}) })
@ -50,7 +55,7 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
popup = tippy(document.body, { popup = tippy(document.body, {
getReferenceClientRect: props.clientRect as GetReferenceClientRect, getReferenceClientRect: props.clientRect as GetReferenceClientRect,
appendTo: () => document.body, appendTo: () => document.body,
content: component.element, content: renderer.element,
showOnCreate: true, showOnCreate: true,
interactive: true, interactive: true,
trigger: 'manual', 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 // Use arrow function here because Nuxt will transform it incorrectly as Vue hook causing the build to fail
onBeforeUpdate: (props) => { onBeforeUpdate: (props) => {
component.updateProps({ ...props, isPending: true }) renderer.updateProps({ ...props, isPending: true })
}, },
onUpdate(props) { onUpdate(props) {
component.updateProps({ ...props, isPending: false }) renderer.updateProps({ ...props, isPending: false })
if (!props.clientRect) if (!props.clientRect)
return return
@ -79,12 +84,12 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
popup?.hide() popup?.hide()
return true return true
} }
return component?.ref?.onKeyDown(props.event) return renderer?.ref?.onKeyDown(props.event)
}, },
onExit() { onExit() {
popup?.destroy() popup?.destroy()
component?.destroy() renderer?.destroy()
}, },
} }
} }

View file

@ -6,6 +6,7 @@
opacity: 0.4; opacity: 0.4;
} }
span[data-type='mention'] { span[data-type='mention'],
span[data-type='hashtag'] {
--at-apply: text-primary; --at-apply: text-primary;
} }