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 { 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,
|
||||||
|
|
|
@ -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()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue