Support suggest item auto-fill (fix #822)

This commit is contained in:
the1812 2025-09-09 21:30:56 +08:00
parent 726b2ef873
commit bbdb70d5d6
6 changed files with 82 additions and 121 deletions

View File

@ -2,6 +2,7 @@
<div <div
tabindex="0" tabindex="0"
class="be-launch-bar-action-item be-launch-bar-suggest-item" class="be-launch-bar-action-item be-launch-bar-suggest-item"
:class="{ focused }"
:title="action.displayName || action.name" :title="action.displayName || action.name"
:data-indexer="action.indexer" :data-indexer="action.indexer"
@click.self="performAction($event)" @click.self="performAction($event)"
@ -51,6 +52,7 @@
import { VIcon } from '@/ui' import { VIcon } from '@/ui'
interface Props { interface Props {
focused?: boolean
action: { action: {
name: string name: string
displayName?: string displayName?: string
@ -99,6 +101,7 @@ const performDelete = async (event: KeyboardEvent | MouseEvent) => {
@include h-center(); @include h-center();
justify-content: center; justify-content: center;
} }
&:not(.disabled).focused,
&:not(.disabled):hover, &:not(.disabled):hover,
&:not(.disabled):focus-within { &:not(.disabled):focus-within {
background-color: #8882; background-color: #8882;

View File

@ -4,11 +4,12 @@
<div class="launch-bar-form"> <div class="launch-bar-form">
<input <input
ref="input" ref="input"
v-model="keyword"
class="input" class="input"
type="text" type="text"
autocomplete="off" autocomplete="off"
:placeholder="recommended.word" :placeholder="recommended.word"
:value="keyword"
@input="handleSearch($event)"
@keydown.enter.stop="handleEnter" @keydown.enter.stop="handleEnter"
@keydown.up.stop="handleUp" @keydown.up.stop="handleUp"
@keydown.down.stop="handleDown" @keydown.down.stop="handleDown"
@ -19,7 +20,7 @@
</div> </div>
<!-- <div class="input-active-bar"></div> --> <!-- <div class="input-active-bar"></div> -->
</div> </div>
<div ref="list" class="launch-bar-suggest-list"> <div class="launch-bar-suggest-list">
<div v-if="isHistory" class="launch-bar-history-list"> <div v-if="isHistory" class="launch-bar-history-list">
<div <div
v-if="actions.length === 0" v-if="actions.length === 0"
@ -32,9 +33,8 @@
v-for="(a, index) of actions" v-for="(a, index) of actions"
:key="a.name" :key="a.name"
:action="a" :action="a"
@previous-item="previousItem()" :focused="index === itemIndex"
@next-item="nextItem()" @delete-item="onDeleteItem()"
@delete-item="onDeleteItem(index)"
@action=" @action="
index === actions.length - 1 && onClearHistory() index === actions.length - 1 && onClearHistory()
onAction() onAction()
@ -43,12 +43,12 @@
</div> </div>
<div v-if="!isHistory" class="launch-bar-action-list"> <div v-if="!isHistory" class="launch-bar-action-list">
<VEmpty <VEmpty
v-if="actions.length === 0 && noActions" v-if="actions.length === 0 && noOnlineActions"
tabindex="0" tabindex="0"
class="be-launch-bar-suggest-item disabled" class="be-launch-bar-suggest-item disabled"
></VEmpty> ></VEmpty>
<VLoading <VLoading
v-if="actions.length === 0 && !noActions" v-if="actions.length === 0 && !noOnlineActions"
tabindex="0" tabindex="0"
class="be-launch-bar-suggest-item disabled" class="be-launch-bar-suggest-item disabled"
></VLoading> ></VLoading>
@ -56,9 +56,8 @@
v-for="(a, index) of actions" v-for="(a, index) of actions"
:key="a.name" :key="a.name"
:action="a" :action="a"
@previous-item="previousItem()" :focused="index === itemIndex"
@next-item="nextItem()" @delete-item="onDeleteItem()"
@delete-item="onDeleteItem(index)"
@action="onAction()" @action="onAction()"
/> />
</div> </div>
@ -66,7 +65,7 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue' import { ref, computed, nextTick, onMounted } from 'vue'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { VIcon, VLoading, VEmpty } from '@/ui' import { VIcon, VLoading, VEmpty } from '@/ui'
import { registerAndGetData } from '@/plugins/data' import { registerAndGetData } from '@/plugins/data'
@ -82,14 +81,13 @@ import {
} from './launch-bar-action' } from './launch-bar-action'
import { searchProvider, search } from './search-provider' import { searchProvider, search } from './search-provider'
import { historyProvider } from './history-provider' import { historyProvider } from './history-provider'
import { FocusTarget } from './focus-target'
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'close'): void (event: 'close'): void
}>() }>()
const input = ref<HTMLInputElement>() const input = ref<HTMLInputElement>()
const list = ref<HTMLElement>() const itemIndex = ref(-1)
const [actionProviders] = registerAndGetData(LaunchBarActionProviders, [ const [actionProviders] = registerAndGetData(LaunchBarActionProviders, [
searchProvider, searchProvider,
@ -102,10 +100,35 @@ const [recommended] = registerAndGetData('launchBar.recommended', {
}) })
const actions = ref<LaunchBarAction[]>([]) const actions = ref<LaunchBarAction[]>([])
const lastKeyword = ref('')
const keyword = ref('') const keyword = ref('')
const focusTarget = new FocusTarget(0) const noOnlineActions = ref(false)
const noActions = ref(false)
const setItemIndex = (index: number) => {
const newIndex = lodash.clamp(index, -1, actions.value.length - 1)
if (itemIndex.value !== newIndex) {
itemIndex.value = newIndex
if (newIndex > -1 && actions.value[newIndex].suggestName) {
keyword.value = actions.value[newIndex].suggestName
if (
lastKeyword.value !== '' &&
keyword.value.toLowerCase().startsWith(lastKeyword.value.toLowerCase())
) {
nextTick(() => {
input.value?.setSelectionRange(lastKeyword.value.length, keyword.value.length)
})
}
} else {
keyword.value = lastKeyword.value
}
}
}
const resetFocus = () => setItemIndex(-1)
const nextItem = () => setItemIndex(itemIndex.value + 1)
const previousItem = () => setItemIndex(itemIndex.value - 1)
const hasFocus = computed(() => itemIndex.value > -1)
const focusedItem = computed(() => actions.value[itemIndex.value])
const isHistory = computed(() => keyword.value.length === 0) const isHistory = computed(() => keyword.value.length === 0)
const sortActions = (actionsList: LaunchBarAction[]) => { const sortActions = (actionsList: LaunchBarAction[]) => {
@ -147,13 +170,13 @@ const getOnlineActionsInternal = async () => {
const fuseResult = fuse.search(keyword.value) const fuseResult = fuse.search(keyword.value)
console.log(fuseResult) console.log(fuseResult)
actions.value = sortActions(fuseResult.map(it => it.item).slice(0, 13)) actions.value = sortActions(fuseResult.map(it => it.item).slice(0, 13))
noActions.value = actions.value.length === 0 noOnlineActions.value = actions.value.length === 0
} }
const getOnlineActions = lodash.debounce(getOnlineActionsInternal, 200) const getOnlineActions = lodash.debounce(getOnlineActionsInternal, 200)
const getActions = async () => { const getActions = async () => {
noActions.value = false noOnlineActions.value = false
if (isHistory.value) { if (isHistory.value) {
actions.value = sortActions( actions.value = sortActions(
generateKeys(historyProvider, await historyProvider.getActions(keyword.value)), generateKeys(historyProvider, await historyProvider.getActions(keyword.value)),
@ -183,13 +206,18 @@ const setupSearchPageSync = async () => {
const handleSelect = () => { const handleSelect = () => {
emit('close') emit('close')
getActions() // getActions()
} }
const handleEnter = async (e?: KeyboardEvent | MouseEvent) => { const handleEnter = async (e?: KeyboardEvent | MouseEvent) => {
if ((e as KeyboardEvent)?.isComposing) { if ((e as KeyboardEvent)?.isComposing) {
return return
} }
if (hasFocus.value) {
await focusedItem.value?.action()
handleSelect()
return
}
if (actions.value.length > 0 && !isHistory.value) { if (actions.value.length > 0 && !isHistory.value) {
const [first] = actions.value as LaunchBarAction[] const [first] = actions.value as LaunchBarAction[]
if (first.explicitSelect !== true) { if (first.explicitSelect !== true) {
@ -210,7 +238,7 @@ const handleUp = (e: KeyboardEvent) => {
if (e.isComposing) { if (e.isComposing) {
return return
} }
focusTarget.previous() previousItem()
e.preventDefault() e.preventDefault()
} }
@ -218,45 +246,24 @@ const handleDown = (e: KeyboardEvent) => {
if (e.isComposing) { if (e.isComposing) {
return return
} }
focusTarget.next() nextItem()
e.preventDefault() e.preventDefault()
} }
const focusInput = () => { const handleSearch = (e: Event) => {
input.value?.focus() keyword.value = (e.target as HTMLInputElement).value
lastKeyword.value = keyword.value
resetFocus()
getActions()
} }
const focusSuggestItem = (nth: number) => { const onDeleteItem = () => {
;( previousItem()
list.value?.querySelector(`.be-launch-bar-suggest-item:nth-child(${nth})`) as HTMLElement
)?.focus()
}
const handleIndexUpdate = async () => {
await nextTick()
if (!focusTarget.hasFocus) {
focusInput()
return
}
focusSuggestItem(focusTarget.index + 1)
}
const previousItem = () => {
focusTarget.previous()
}
const nextItem = () => {
focusTarget.next()
}
const onDeleteItem = (index: number) => {
focusTarget.setFocus(index)
focusTarget.previous()
getActions() getActions()
} }
const onClearHistory = () => { const onClearHistory = () => {
focusInput() resetFocus()
getActions() getActions()
} }
@ -264,27 +271,16 @@ const onAction = () => {
handleSelect() handleSelect()
} }
watch(keyword, () => {
getActions()
})
watch(actions, () => {
focusTarget.reset(actions.value.length)
})
onMounted(async () => { onMounted(async () => {
await getActions() await getActions()
if (matchUrlPattern(/^https?:\/\/search\.bilibili\.com/)) { if (matchUrlPattern(/^https?:\/\/search\.bilibili\.com/)) {
await setupSearchPageSync() await setupSearchPageSync()
} }
focusTarget.addEventListener('index-change', () => {
handleIndexUpdate()
})
}) })
defineExpose({ defineExpose({
input, input,
focusInput, focusInput: resetFocus,
}) })
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@ -1,44 +0,0 @@
export class FocusTarget extends EventTarget {
/**
* -1: Input Focus
* > -1: Item Focus
*/
private itemIndex = -1
private itemLength = 0
constructor(length: number, index = -1) {
super()
this.itemLength = length
this.index = index
}
get index() {
return this.itemIndex
}
private set index(value: number) {
const newIndex = lodash.clamp(value, -1, this.itemLength - 1)
if (this.itemIndex !== newIndex) {
this.itemIndex = newIndex
this.dispatchEvent(new CustomEvent('index-change', { detail: this }))
}
}
get hasFocus() {
return this.itemIndex > -1
}
setFocus(index: number) {
this.index = index
}
reset(length: number, index = this.index) {
this.itemLength = length
this.index = index
}
next() {
this.index += 1
console.log(this.index)
}
previous() {
this.index -= 1
console.log(this.index)
}
}

View File

@ -68,6 +68,7 @@ export const historyProvider: LaunchBarActionProvider = {
icon: 'mdi-history', icon: 'mdi-history',
// description: `在 ${formatDate(new Date(it.timestamp))} 搜索过`, // description: `在 ${formatDate(new Date(it.timestamp))} 搜索过`,
explicitSelect: true, explicitSelect: true,
suggestName: it.value,
action: () => { action: () => {
search(it.value) search(it.value)
}, },

View File

@ -20,6 +20,8 @@ export interface LaunchBarAction {
deleteAction?: Executable deleteAction?: Executable
/** 显式选中模式: 开启后可以禁止在列表第一项时直接由 Enter 触发 */ /** 显式选中模式: 开启后可以禁止在列表第一项时直接由 Enter 触发 */
explicitSelect?: boolean explicitSelect?: boolean
/** 用于搜索建议的名称, 在搜索结果中选中时会回填到输入框中 */
suggestName?: string
/** 手动指定在搜索结果中的顺序, 数字越小越排前面 */ /** 手动指定在搜索结果中的顺序, 数字越小越排前面 */
order?: number order?: number
} }

View File

@ -47,9 +47,11 @@ export const searchProvider: LaunchBarActionProvider = {
return results return results
} }
results.push( results.push(
...suggests.map(result => ({ ...suggests.map(
(result): LaunchBarAction => ({
name: `${input}.${result.value}`, name: `${input}.${result.value}`,
icon: 'search', icon: 'search',
suggestName: result.value,
content: async () => content: async () =>
Vue.extend({ Vue.extend({
render: h => { render: h => {
@ -62,7 +64,8 @@ export const searchProvider: LaunchBarActionProvider = {
}, },
}), }),
action: () => search(result.value), action: () => search(result.value),
})), }),
),
) )
return lodash.uniqBy(results, it => it.name) return lodash.uniqBy(results, it => it.name)
}, },