forked from Mirrors/elk
242 lines
5.8 KiB
Vue
242 lines
5.8 KiB
Vue
<script setup lang="ts">
|
|
import type { SearchResult as SearchResultType } from '@/components/search/types'
|
|
import type { CommandScope, QueryResult, QueryResultItem } from '@/composables/command'
|
|
|
|
const emit = defineEmits<{
|
|
(event: 'close'): void
|
|
}>()
|
|
|
|
const registry = useCommandRegistry()
|
|
|
|
const router = useRouter()
|
|
|
|
const inputEl = $ref<HTMLInputElement>()
|
|
const resultEl = $ref<HTMLDivElement>()
|
|
|
|
const scopes = $ref<CommandScope[]>([])
|
|
let input = $(commandPanelInput)
|
|
|
|
onMounted(() => {
|
|
inputEl?.focus()
|
|
})
|
|
|
|
const commandMode = $computed(() => input.startsWith('>'))
|
|
|
|
const query = $computed(() => commandMode ? '' : input.trim())
|
|
|
|
const { accounts, hashtags, loading } = useSearch($$(query))
|
|
|
|
const toSearchQueryResultItem = (search: SearchResultType): QueryResultItem => ({
|
|
index: 0,
|
|
type: 'search',
|
|
search,
|
|
onActivate: () => router.push(search.to),
|
|
})
|
|
|
|
const searchResult = $computed<QueryResult>(() => {
|
|
if (query.length === 0 || loading.value)
|
|
return { length: 0, items: [], grouped: {} as any }
|
|
|
|
const hashtagList = hashtags.value.slice(0, 3)
|
|
.map<SearchResultType>(hashtag => ({ type: 'hashtag', hashtag, to: `/tags/${hashtag.name}` }))
|
|
.map(toSearchQueryResultItem)
|
|
const accountList = accounts.value
|
|
.map<SearchResultType>(account => ({ type: 'account', account, to: `/@${account.acct}` }))
|
|
.map(toSearchQueryResultItem)
|
|
|
|
const grouped: QueryResult['grouped'] = new Map()
|
|
grouped.set('Hashtags', hashtagList)
|
|
grouped.set('Users', accountList)
|
|
|
|
let index = 0
|
|
for (const items of grouped.values()) {
|
|
for (const item of items)
|
|
item.index = index++
|
|
}
|
|
|
|
return {
|
|
grouped,
|
|
items: [...hashtagList, ...accountList],
|
|
length: hashtagList.length + accountList.length,
|
|
}
|
|
})
|
|
|
|
const result = $computed<QueryResult>(() => commandMode
|
|
? registry.query(scopes.map(s => s.id).join('.'), input.slice(1))
|
|
: searchResult,
|
|
)
|
|
|
|
let active = $ref(0)
|
|
watch($$(result), (n, o) => {
|
|
if (n.length !== o.length || !n.items.every((i, idx) => i === o.items[idx]))
|
|
active = 0
|
|
})
|
|
|
|
const findItemEl = (index: number) =>
|
|
resultEl?.querySelector(`[data-index="${index}"]`) as HTMLDivElement | null
|
|
const onCommandActivate = (item: QueryResultItem) => {
|
|
if (item.onActivate) {
|
|
item.onActivate()
|
|
emit('close')
|
|
}
|
|
else if (item.onComplete) {
|
|
scopes.push(item.onComplete())
|
|
input = '>'
|
|
}
|
|
}
|
|
const onCommandComplete = (item: QueryResultItem) => {
|
|
if (item.onComplete) {
|
|
scopes.push(item.onComplete())
|
|
input = '>'
|
|
}
|
|
else if (item.onActivate) {
|
|
item.onActivate()
|
|
emit('close')
|
|
}
|
|
}
|
|
const intoView = (index: number) => {
|
|
const el = findItemEl(index)
|
|
if (el)
|
|
el.scrollIntoView({ block: 'nearest' })
|
|
}
|
|
|
|
function setActive(index: number) {
|
|
const len = result.length
|
|
active = (index + len) % len
|
|
intoView(active)
|
|
}
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
switch (e.key) {
|
|
case 'p':
|
|
case 'ArrowUp': {
|
|
if (e.key === 'p' && !e.ctrlKey)
|
|
break
|
|
e.preventDefault()
|
|
|
|
setActive(active - 1)
|
|
|
|
break
|
|
}
|
|
case 'n':
|
|
case 'ArrowDown': {
|
|
if (e.key === 'n' && !e.ctrlKey)
|
|
break
|
|
e.preventDefault()
|
|
|
|
setActive(active + 1)
|
|
|
|
break
|
|
}
|
|
|
|
case 'Home': {
|
|
e.preventDefault()
|
|
|
|
active = 0
|
|
|
|
intoView(active)
|
|
|
|
break
|
|
}
|
|
|
|
case 'End': {
|
|
e.preventDefault()
|
|
|
|
setActive(result.length - 1)
|
|
|
|
break
|
|
}
|
|
|
|
case 'Enter': {
|
|
e.preventDefault()
|
|
|
|
const cmd = result.items[active]
|
|
if (cmd)
|
|
onCommandActivate(cmd)
|
|
|
|
break
|
|
}
|
|
|
|
case 'Tab': {
|
|
e.preventDefault()
|
|
|
|
const cmd = result.items[active]
|
|
if (cmd)
|
|
onCommandComplete(cmd)
|
|
|
|
break
|
|
}
|
|
|
|
case 'Backspace': {
|
|
if (input === '>' && scopes.length) {
|
|
e.preventDefault()
|
|
scopes.pop()
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col w-50vw max-w-180 h-50vh max-h-120">
|
|
<!-- Input -->
|
|
<label class="flex mx-3 my-1 items-center">
|
|
<div mx-1 i-ri:search-line />
|
|
|
|
<div v-for="scope in scopes" :key="scope.id" class="flex items-center mx-1 gap-2">
|
|
<div class="text-sm">{{ scope.display }}</div>
|
|
<span class="text-secondary">/</span>
|
|
</div>
|
|
|
|
<input
|
|
ref="inputEl"
|
|
v-model="input"
|
|
class="focus:outline-none flex-1 p-2 rounded bg-base"
|
|
placeholder="Search"
|
|
@keydown="onKeyDown"
|
|
>
|
|
|
|
<CommandKey name="Escape" />
|
|
</label>
|
|
|
|
<div class="w-full border-b-1 border-base" />
|
|
|
|
<!-- Results -->
|
|
<div ref="resultEl" class="flex-1 mx-1 overflow-y-auto">
|
|
<template v-if="loading">
|
|
<SearchResultSkeleton />
|
|
<SearchResultSkeleton />
|
|
<SearchResultSkeleton />
|
|
</template>
|
|
<template v-else-if="result.length">
|
|
<template v-for="[scope, group] in result.grouped" :key="scope">
|
|
<div class="mt-2 px-2 py-1 text-sm text-secondary">
|
|
{{ scope }}
|
|
</div>
|
|
|
|
<template v-for="item in group" :key="item.index">
|
|
<SearchResult v-if="item.type === 'search'" :active="active === item.index" :result="item.search" />
|
|
<CommandItem v-else :index="item.index" :cmd="item.cmd" :active="active === item.index" @activate="onCommandActivate(item)" />
|
|
</template>
|
|
</template>
|
|
</template>
|
|
<div v-else p5 text-center text-secondary italic>
|
|
{{
|
|
input.length
|
|
? $t('common.not_found')
|
|
: $t('search.search_desc')
|
|
}}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="w-full border-b-1 border-base" />
|
|
|
|
<!-- Footer -->
|
|
<div class="flex items-center px-3 py-1 text-xs">
|
|
<div i-ri:lightbulb-flash-line /> Tip: Use
|
|
<CommandKey name="Ctrl+K" /> to search,
|
|
<CommandKey name="Ctrl+/" /> to activate command mode.
|
|
</div>
|
|
</div>
|
|
</template>
|