268 lines
7.2 KiB
Vue
268 lines
7.2 KiB
Vue
<script setup lang="ts">
|
|
import type { MenuRecordRaw } from '@easyflow/types';
|
|
|
|
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
|
|
import { SearchX } from '@easyflow/icons';
|
|
import { $t } from '@easyflow/locales';
|
|
import { mapTree, traverseTreeValues, uniqueByField } from '@easyflow/utils';
|
|
|
|
import { EasyFlowIcon, EasyFlowScrollbar } from '@easyflow-core/shadcn-ui';
|
|
import { isHttpUrl } from '@easyflow-core/shared/utils';
|
|
|
|
import { onKeyStroke, useThrottleFn } from '@vueuse/core';
|
|
|
|
defineOptions({
|
|
name: 'SearchPanel',
|
|
});
|
|
|
|
const props = withDefaults(
|
|
defineProps<{ keyword?: string; menus?: MenuRecordRaw[] }>(),
|
|
{
|
|
keyword: '',
|
|
menus: () => [],
|
|
},
|
|
);
|
|
const emit = defineEmits<{ close: []; resultsChange: [count: number] }>();
|
|
|
|
const router = useRouter();
|
|
const activeIndex = ref(-1);
|
|
const searchItems = shallowRef<MenuRecordRaw[]>([]);
|
|
const searchResults = ref<MenuRecordRaw[]>([]);
|
|
const displayResults = computed(() =>
|
|
uniqueByField(searchResults.value, 'path'),
|
|
);
|
|
|
|
const handleSearch = useThrottleFn(search, 200);
|
|
|
|
function search(searchKey: string) {
|
|
const normalizedKey = searchKey.trim();
|
|
if (!normalizedKey) {
|
|
searchResults.value = [];
|
|
activeIndex.value = -1;
|
|
return;
|
|
}
|
|
|
|
const reg = createSearchReg(normalizedKey);
|
|
const results: MenuRecordRaw[] = [];
|
|
|
|
traverseTreeValues(searchItems.value, (item) => {
|
|
if (reg.test(item.name?.toLowerCase())) {
|
|
results.push(item);
|
|
}
|
|
});
|
|
|
|
searchResults.value = uniqueByField(results, 'path');
|
|
activeIndex.value = searchResults.value.length > 0 ? 0 : -1;
|
|
}
|
|
|
|
function scrollIntoView() {
|
|
const element = document.querySelector(
|
|
`[data-search-item="${activeIndex.value}"]`,
|
|
);
|
|
|
|
if (element) {
|
|
element.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}
|
|
|
|
async function handleEnter() {
|
|
if (displayResults.value.length === 0 || activeIndex.value < 0) {
|
|
return;
|
|
}
|
|
await handleSelect(activeIndex.value);
|
|
}
|
|
|
|
async function handleSelect(index: number) {
|
|
const to = displayResults.value[index];
|
|
if (!to) {
|
|
return;
|
|
}
|
|
emit('close');
|
|
await nextTick();
|
|
if (isHttpUrl(to.path)) {
|
|
window.open(to.path, '_blank');
|
|
} else {
|
|
router.push({ path: to.path, replace: true });
|
|
}
|
|
}
|
|
|
|
function handleUp() {
|
|
if (displayResults.value.length === 0) {
|
|
return;
|
|
}
|
|
activeIndex.value--;
|
|
if (activeIndex.value < 0) {
|
|
activeIndex.value = displayResults.value.length - 1;
|
|
}
|
|
scrollIntoView();
|
|
}
|
|
|
|
function handleDown() {
|
|
if (displayResults.value.length === 0) {
|
|
return;
|
|
}
|
|
activeIndex.value++;
|
|
if (activeIndex.value > displayResults.value.length - 1) {
|
|
activeIndex.value = 0;
|
|
}
|
|
scrollIntoView();
|
|
}
|
|
|
|
function handleClose() {
|
|
emit('close');
|
|
}
|
|
|
|
function handleMouseenter(index: number) {
|
|
activeIndex.value = index;
|
|
}
|
|
|
|
function getHighlightedChunks(text: string, keyword: string) {
|
|
const sourceChars = [...text];
|
|
const lowerChars = [...text.toLowerCase()];
|
|
const queryChars = [...keyword.trim().toLowerCase()];
|
|
|
|
if (queryChars.length === 0) {
|
|
return [{ text, matched: false }];
|
|
}
|
|
|
|
const chunks: Array<{ matched: boolean; text: string }> = [];
|
|
let queryIndex = 0;
|
|
|
|
sourceChars.forEach((char, index) => {
|
|
const matched =
|
|
queryIndex < queryChars.length &&
|
|
lowerChars[index] === queryChars[queryIndex];
|
|
if (matched) {
|
|
queryIndex++;
|
|
}
|
|
const previousChunk = chunks[chunks.length - 1];
|
|
if (previousChunk && previousChunk.matched === matched) {
|
|
previousChunk.text += char;
|
|
} else {
|
|
chunks.push({ matched, text: char });
|
|
}
|
|
});
|
|
|
|
return chunks;
|
|
}
|
|
|
|
const code = new Set([
|
|
'$',
|
|
'(',
|
|
')',
|
|
'*',
|
|
'+',
|
|
'.',
|
|
'?',
|
|
'[',
|
|
'\\',
|
|
']',
|
|
'^',
|
|
'{',
|
|
'|',
|
|
'}',
|
|
]);
|
|
|
|
function transform(c: string) {
|
|
return code.has(c) ? `\\${c}` : c;
|
|
}
|
|
|
|
function createSearchReg(key: string) {
|
|
const keys = [...key].map((item) => transform(item)).join('.*');
|
|
return new RegExp(`.*${keys}.*`);
|
|
}
|
|
|
|
watch(
|
|
() => props.keyword,
|
|
(val) => {
|
|
if (val) {
|
|
handleSearch(val);
|
|
} else {
|
|
searchResults.value = [];
|
|
activeIndex.value = -1;
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => displayResults.value.length,
|
|
(count) => {
|
|
emit('resultsChange', count);
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
onMounted(() => {
|
|
searchItems.value = mapTree(props.menus, (item) => {
|
|
return {
|
|
...item,
|
|
name: $t(item?.name),
|
|
};
|
|
});
|
|
onKeyStroke('Enter', handleEnter);
|
|
onKeyStroke('ArrowUp', handleUp);
|
|
onKeyStroke('ArrowDown', handleDown);
|
|
onKeyStroke('Escape', handleClose);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<EasyFlowScrollbar>
|
|
<div class="px-2 py-1 sm:max-h-[420px]">
|
|
<ul v-if="displayResults.length > 0" class="space-y-2">
|
|
<li
|
|
v-for="(item, index) in displayResults"
|
|
:key="item.path"
|
|
:class="
|
|
activeIndex === index
|
|
? 'bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.22,hsl(var(--glass-tint))/0.1_18%,hsl(var(--surface-panel))/0.12)] shadow-[inset_0_1px_0_hsl(var(--glass-border))/0.34,0_18px_30px_-24px_hsl(var(--primary)/0.12)] ring-1 ring-[hsl(var(--glass-border))/0.26]'
|
|
: ''
|
|
"
|
|
:data-index="index"
|
|
:data-search-item="index"
|
|
class="group flex w-full cursor-pointer items-center gap-3 rounded-2xl bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.14,hsl(var(--glass-tint))/0.08_18%,hsl(var(--surface-panel))/0.08)] px-4 py-3.5 backdrop-blur-2xl transition-[transform,background-color,box-shadow] hover:-translate-y-0.5 hover:bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.18,hsl(var(--glass-tint))/0.12_18%,hsl(var(--surface-panel))/0.1)] hover:shadow-[inset_0_1px_0_hsl(var(--glass-border))/0.28,0_16px_28px_-24px_hsl(var(--primary)/0.1)]"
|
|
@click="handleSelect(index)"
|
|
@mouseenter="handleMouseenter(index)"
|
|
>
|
|
<div
|
|
class="flex size-10 flex-shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.24,hsl(var(--nav-item-active))/0.09_22%,hsl(var(--glass-tint))/0.14)] text-[hsl(var(--nav-item-active-foreground))] shadow-[inset_0_1px_0_hsl(var(--glass-border))/0.36]"
|
|
>
|
|
<EasyFlowIcon :icon="item.icon" class="size-5" fallback />
|
|
</div>
|
|
|
|
<div class="min-w-0 flex-1">
|
|
<span
|
|
class="block truncate text-sm font-medium leading-6 text-[hsl(var(--text-strong))]"
|
|
>
|
|
<template
|
|
v-for="chunk in getHighlightedChunks(
|
|
item.name || '',
|
|
keyword,
|
|
)"
|
|
:key="`${item.path}-${chunk.text}-${chunk.matched}`"
|
|
>
|
|
<span
|
|
:class="
|
|
chunk.matched
|
|
? activeIndex === index
|
|
? 'rounded-md bg-[hsl(var(--primary))/0.1] px-0.5 font-semibold text-current'
|
|
: 'rounded-md bg-[hsl(var(--primary))/0.08] px-0.5 font-semibold text-[hsl(var(--primary))]'
|
|
: ''
|
|
"
|
|
>
|
|
{{ chunk.text }}
|
|
</span>
|
|
</template>
|
|
</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
<div v-else class="hidden">
|
|
<SearchX class="size-0" />
|
|
</div>
|
|
</div>
|
|
</EasyFlowScrollbar>
|
|
</template>
|