feat: search in command panel (#576)
parent
d585d4eeb7
commit
a775203bdc
|
@ -0,0 +1,58 @@
|
|||
<script lang="ts" setup>
|
||||
import type { ResolvedCommand } from '@/composables/command'
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'activate'): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
cmd,
|
||||
index,
|
||||
active = false,
|
||||
} = $defineProps<{
|
||||
cmd: ResolvedCommand
|
||||
index: number
|
||||
active?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex px-3 py-2 my-1 items-center rounded-lg hover:bg-active transition-all duration-65 ease-in-out cursor-pointer scroll-m-10"
|
||||
:class="{ 'bg-active': active }"
|
||||
:data-index="index"
|
||||
@click="emits('activate')"
|
||||
>
|
||||
<div v-if="cmd.icon" mr-2 :class="cmd.icon" />
|
||||
|
||||
<div class="flex-1 flex items-baseline gap-2">
|
||||
<div :class="{ 'font-medium': active }">
|
||||
{{ cmd.name }}
|
||||
</div>
|
||||
<div v-if="cmd.description" class="text-xs text-secondary">
|
||||
{{ cmd.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="cmd.onComplete"
|
||||
class="flex items-center gap-1 transition-all duration-65 ease-in-out"
|
||||
:class="active ? 'opacity-100' : 'opacity-0'"
|
||||
>
|
||||
<div class="text-xs text-secondary">
|
||||
{{ $t('command.complete') }}
|
||||
</div>
|
||||
<CommandKey name="Tab" />
|
||||
</div>
|
||||
<div
|
||||
v-if="cmd.onActivate"
|
||||
class="flex items-center gap-1 transition-all duration-65 ease-in-out"
|
||||
:class="active ? 'opacity-100' : 'opacity-0'"
|
||||
>
|
||||
<div class="text-xs text-secondary">
|
||||
{{ $t('command.activate') }}
|
||||
</div>
|
||||
<CommandKey name="Enter" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { CommandScope, QueryIndexedCommand } from '@/composables/command'
|
||||
import type { SearchResult as SearchResultType } from '@/components/search/types'
|
||||
import type { CommandScope, QueryResult, QueryResultItem } from '@/composables/command'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
|
@ -7,6 +8,8 @@ const emit = defineEmits<{
|
|||
|
||||
const registry = useCommandRegistry()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const inputEl = $ref<HTMLInputElement>()
|
||||
const resultEl = $ref<HTMLDivElement>()
|
||||
|
||||
|
@ -18,9 +21,51 @@ onMounted(() => {
|
|||
})
|
||||
|
||||
const commandMode = $computed(() => input.startsWith('>'))
|
||||
const result = $computed(() => commandMode
|
||||
|
||||
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))
|
||||
: { length: 0, items: [], grouped: {} })
|
||||
: searchResult,
|
||||
)
|
||||
|
||||
let active = $ref(0)
|
||||
watch($$(result), (n, o) => {
|
||||
if (n.length !== o.length || !n.items.every((i, idx) => i === o.items[idx]))
|
||||
|
@ -29,7 +74,7 @@ watch($$(result), (n, o) => {
|
|||
|
||||
const findItemEl = (index: number) =>
|
||||
resultEl?.querySelector(`[data-index="${index}"]`) as HTMLDivElement | null
|
||||
const onCommandActivate = (item: QueryIndexedCommand) => {
|
||||
const onCommandActivate = (item: QueryResultItem) => {
|
||||
if (item.onActivate) {
|
||||
item.onActivate()
|
||||
emit('close')
|
||||
|
@ -39,13 +84,14 @@ const onCommandActivate = (item: QueryIndexedCommand) => {
|
|||
input = '>'
|
||||
}
|
||||
}
|
||||
const onCommandComplete = (item: QueryIndexedCommand) => {
|
||||
const onCommandComplete = (item: QueryResultItem) => {
|
||||
if (item.onComplete) {
|
||||
scopes.push(item.onComplete())
|
||||
input = '>'
|
||||
}
|
||||
else if (item.onActivate) {
|
||||
item.onActivate()
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
const intoView = (index: number) => {
|
||||
|
@ -158,52 +204,30 @@ const onKeyDown = (e: KeyboardEvent) => {
|
|||
|
||||
<!-- 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="cmd in group" :key="cmd.index">
|
||||
<div
|
||||
class="flex px-3 py-2 my-1 items-center rounded-lg hover:bg-active transition-all duration-65 ease-in-out cursor-pointer scroll-m-10"
|
||||
:class="{ 'bg-active': active === cmd.index }"
|
||||
:data-index="cmd.index"
|
||||
@click="onCommandActivate(cmd)"
|
||||
>
|
||||
<div v-if="cmd.icon" mr-2 :class="cmd.icon" />
|
||||
|
||||
<div class="flex-1 flex items-baseline gap-2">
|
||||
<div :class="{ 'font-medium': active === cmd.index }">
|
||||
{{ cmd.name }}
|
||||
</div>
|
||||
<div v-if="cmd.description" class="text-xs text-secondary">
|
||||
{{ cmd.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="cmd.onComplete"
|
||||
class="flex items-center gap-1 transition-all duration-65 ease-in-out"
|
||||
:class="active === cmd.index ? 'opacity-100' : 'opacity-0'"
|
||||
>
|
||||
<div class="text-xs text-secondary">
|
||||
{{ $t('command.complete') }}
|
||||
</div>
|
||||
<CommandKey name="Tab" />
|
||||
</div>
|
||||
<div
|
||||
v-if="cmd.onActivate"
|
||||
class="flex items-center gap-1 transition-all duration-65 ease-in-out"
|
||||
:class="active === cmd.index ? 'opacity-100' : 'opacity-0'"
|
||||
>
|
||||
<div class="text-xs text-secondary">
|
||||
{{ $t('command.activate') }}
|
||||
</div>
|
||||
<CommandKey name="Enter" />
|
||||
</div>
|
||||
</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" />
|
||||
|
|
|
@ -2,6 +2,7 @@ import type { ComputedRef } from 'vue'
|
|||
import { defineStore } from 'pinia'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { LocaleObject } from '#i18n'
|
||||
import type { SearchResult } from '@/components/search/types'
|
||||
|
||||
// @unocss-include
|
||||
|
||||
|
@ -15,6 +16,8 @@ const scopes = [
|
|||
'Languages',
|
||||
'Switch account',
|
||||
'Settings',
|
||||
'Hashtags',
|
||||
'Users',
|
||||
] as const
|
||||
|
||||
export type CommandScopeNames = typeof scopes[number]
|
||||
|
@ -42,16 +45,37 @@ export interface CommandProvider {
|
|||
onComplete?: () => CommandScope
|
||||
}
|
||||
|
||||
export type ResolvedCommand =
|
||||
Exclude<CommandProvider, 'icon' | 'name' | 'description' | 'bindings'> & {
|
||||
export type ResolvedCommand = Exclude<CommandProvider, 'icon' | 'name' | 'description' | 'bindings'> & {
|
||||
icon: string
|
||||
name: string
|
||||
description: string | undefined
|
||||
bindings: string[] | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type QueryIndexedCommand = ResolvedCommand & {
|
||||
export interface BaseQueryResultItem {
|
||||
index: number
|
||||
type: string
|
||||
scope?: CommandScopeNames
|
||||
onActivate?: () => void
|
||||
onComplete?: () => CommandScope
|
||||
}
|
||||
|
||||
export interface SearchQueryResultItem extends BaseQueryResultItem {
|
||||
type: 'search'
|
||||
search: SearchResult
|
||||
}
|
||||
|
||||
export interface CommandQueryResultItem extends BaseQueryResultItem {
|
||||
type: 'command'
|
||||
cmd: ResolvedCommand
|
||||
}
|
||||
|
||||
export type QueryResultItem = SearchQueryResultItem | CommandQueryResultItem
|
||||
|
||||
export interface QueryResult {
|
||||
length: number
|
||||
items: QueryResultItem[]
|
||||
grouped: Map<CommandScopeNames, QueryResultItem[]>
|
||||
}
|
||||
|
||||
const r = <T extends Object | undefined>(i: T | (() => T)): T =>
|
||||
|
@ -86,7 +110,7 @@ export const useCommandRegistry = defineStore('command', () => {
|
|||
providers.delete(provider)
|
||||
},
|
||||
|
||||
query: (scope: string, query: string) => {
|
||||
query: (scope: string, query: string): QueryResult => {
|
||||
const cmds = commands.value
|
||||
.filter(cmd => (cmd.parent ?? '') === scope)
|
||||
|
||||
|
@ -105,7 +129,7 @@ export const useCommandRegistry = defineStore('command', () => {
|
|||
.map(({ item }) => ({ ...item }))
|
||||
|
||||
// group by scope
|
||||
const grouped = new Map<CommandScopeNames, QueryIndexedCommand[]>()
|
||||
const grouped = new Map<CommandScopeNames, CommandQueryResultItem[]>()
|
||||
for (const cmd of res) {
|
||||
const scope = cmd.scope ?? ''
|
||||
if (!grouped.has(scope))
|
||||
|
@ -113,13 +137,17 @@ export const useCommandRegistry = defineStore('command', () => {
|
|||
grouped
|
||||
.get(scope)!
|
||||
.push({
|
||||
...cmd,
|
||||
index: 0,
|
||||
type: 'command',
|
||||
scope,
|
||||
cmd,
|
||||
onActivate: cmd.onActivate,
|
||||
onComplete: cmd.onComplete,
|
||||
})
|
||||
}
|
||||
|
||||
let index = 0
|
||||
const indexed: QueryIndexedCommand[] = []
|
||||
const indexed: CommandQueryResultItem[] = []
|
||||
for (const items of grouped.values()) {
|
||||
for (const cmd of items) {
|
||||
cmd.index = index++
|
||||
|
@ -137,21 +165,28 @@ export const useCommandRegistry = defineStore('command', () => {
|
|||
else {
|
||||
const indexed = cmds.map((cmd, index) => ({ ...cmd, index }))
|
||||
|
||||
const grouped = new Map<CommandScopeNames, QueryIndexedCommand[]>(
|
||||
const grouped = new Map<CommandScopeNames, CommandQueryResultItem[]>(
|
||||
scopes.map(scope => [scope, []]))
|
||||
for (const cmd of indexed) {
|
||||
const scope = cmd.scope ?? ''
|
||||
grouped.get(scope)!.push(cmd)
|
||||
grouped.get(scope)!.push({
|
||||
index: cmd.index,
|
||||
type: 'command',
|
||||
scope,
|
||||
cmd,
|
||||
onActivate: cmd.onActivate,
|
||||
onComplete: cmd.onComplete,
|
||||
})
|
||||
}
|
||||
|
||||
let index = 0
|
||||
const sorted: QueryIndexedCommand[] = []
|
||||
const sorted: CommandQueryResultItem[] = []
|
||||
for (const [scope, items] of grouped) {
|
||||
if (items.length === 0) {
|
||||
grouped.delete(scope)
|
||||
}
|
||||
else {
|
||||
const o = (cmd: QueryIndexedCommand) => (cmd.order ?? 0) * 100 + cmd.index
|
||||
const o = (item: CommandQueryResultItem) => (item.cmd.order ?? 0) * 100 + item.index
|
||||
items.sort((a, b) => o(a) - o(b))
|
||||
for (const cmd of items) {
|
||||
cmd.index = index++
|
||||
|
|
Loading…
Reference in New Issue