mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
286 lines
11 KiB
Svelte
286 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { onMount, tick } from 'svelte';
|
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
|
import { fade, scale } from 'svelte/transition';
|
|
import { circOut, quintOut } from 'svelte/easing';
|
|
import { getStaticCommands, type StaticCommandItem } from './commands';
|
|
import { getAllDynamicItems, type DynamicContentItem } from './dynamicSearch';
|
|
import type { CombinedResult } from './types';
|
|
import { createSearchIndexes, performSearch as doSearch } from './searchUtils';
|
|
import { highlightMatch, highlightSnippet } from './highlightUtils';
|
|
import Fuse from 'fuse.js';
|
|
import Calculator from './Calculator.svelte';
|
|
|
|
const { transparencyEffects } = $props<{ transparencyEffects: boolean }>();
|
|
|
|
let commandsFuse = $state<Fuse<StaticCommandItem>>();
|
|
let dynamicContentFuse = $state<Fuse<DynamicContentItem>>();
|
|
|
|
const dynamicIdToItemMap = $state(new Map<string, DynamicContentItem>());
|
|
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
|
|
|
|
function setupSearchIndexes() {
|
|
const { commandsFuse: cfuse, dynamicContentFuse: dfuse, commands, dynamicItems } = createSearchIndexes();
|
|
|
|
commandsFuse = cfuse;
|
|
dynamicContentFuse = dfuse;
|
|
|
|
dynamicIdToItemMap.clear();
|
|
commandIdToItemMap.clear();
|
|
|
|
dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item));
|
|
commands.forEach(item => commandIdToItemMap.set(item.id, item));
|
|
|
|
console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
|
|
}
|
|
|
|
let commandPalleteOpen = $state(false);
|
|
let searchTerm = $state('');
|
|
let selectedIndex = $state(0);
|
|
let searchbar = $state<HTMLInputElement>();
|
|
let combinedResults = $state<CombinedResult[]>([]);
|
|
let isLoading = $state(false);
|
|
let prevSearchTerm = $state('');
|
|
let calculatorResult = $state<string | null>(null);
|
|
|
|
// Function to check if calculator has a result
|
|
const updateCalculatorState = (hasResult: string | null) => {
|
|
calculatorResult = hasResult;
|
|
};
|
|
|
|
onMount(() => {
|
|
setupSearchIndexes();
|
|
|
|
// @ts-ignore
|
|
window.setCommandPalleteOpen = (open: boolean) => {
|
|
commandPalleteOpen = open;
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
e.preventDefault();
|
|
commandPalleteOpen = true;
|
|
tick().then(() => searchbar?.focus());
|
|
}
|
|
if (e.key === 'Escape') {
|
|
commandPalleteOpen = false;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
});
|
|
|
|
const performSearch = () => {
|
|
isLoading = true;
|
|
selectedIndex = 0;
|
|
|
|
const term = searchTerm.trim().toLowerCase();
|
|
|
|
if (commandsFuse && dynamicContentFuse) {
|
|
combinedResults = doSearch(
|
|
term,
|
|
commandsFuse,
|
|
dynamicContentFuse,
|
|
commandIdToItemMap,
|
|
dynamicIdToItemMap
|
|
);
|
|
} else {
|
|
combinedResults = [];
|
|
}
|
|
|
|
isLoading = false;
|
|
};
|
|
|
|
$effect(() => {
|
|
if (commandPalleteOpen) {
|
|
performSearch();
|
|
tick().then(() => searchbar?.focus());
|
|
} else {
|
|
searchTerm = '';
|
|
selectedIndex = 0;
|
|
prevSearchTerm = '';
|
|
combinedResults = [];
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (commandPalleteOpen && searchTerm !== prevSearchTerm) {
|
|
prevSearchTerm = searchTerm;
|
|
performSearch();
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (combinedResults.length === 0 && calculatorResult && commandPalleteOpen) {
|
|
selectedIndex = 0;
|
|
}
|
|
});
|
|
|
|
const selectNext = () => {
|
|
const maxIndex = (calculatorResult ? 1 : 0) + combinedResults.length - 1;
|
|
if (selectedIndex < maxIndex) {
|
|
selectedIndex++;
|
|
}
|
|
};
|
|
|
|
const selectPrev = () => {
|
|
if (selectedIndex > 0) {
|
|
selectedIndex--;
|
|
}
|
|
};
|
|
|
|
const executeSelected = () => {
|
|
if (calculatorResult && selectedIndex === 0) {
|
|
navigator.clipboard.writeText(calculatorResult);
|
|
} else {
|
|
const resultIndex = calculatorResult ? selectedIndex - 1 : selectedIndex;
|
|
combinedResults[resultIndex]?.item.action();
|
|
}
|
|
commandPalleteOpen = false;
|
|
};
|
|
|
|
const handleKeyNav = (e: KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
selectNext();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
selectPrev();
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
executeSelected();
|
|
} else if (e.key === 'Escape') {
|
|
commandPalleteOpen = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
{#if commandPalleteOpen}
|
|
<div role="dialog" aria-modal="true" class={settingsState.DarkMode ? 'dark' : ''}>
|
|
<div
|
|
class="fixed inset-0 z-[50000] bg-zinc-900/40 dark:bg-black/60"
|
|
transition:fade={{ duration: 150, easing: quintOut }}
|
|
></div>
|
|
|
|
<div class="fixed inset-0 z-[50000] flex justify-center place-items-start p-8 sm:p-6 md:p-8 select-none"
|
|
onclick={() => commandPalleteOpen = false}
|
|
onkeydown={(e) => e.key === 'Escape' && (commandPalleteOpen = false)}
|
|
role="button"
|
|
tabindex="0">
|
|
<div
|
|
class="w-full max-w-2xl rounded-xl ring-1 shadow-2xl ring-black/5 dark:ring-white/10 { transparencyEffects ? 'bg-white/80 dark:bg-zinc-900/80 backdrop-blur' : 'bg-white dark:bg-zinc-900' }"
|
|
transition:scale={{ duration: 100, start: 0.95, opacity: 0, easing: circOut }}
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
commandPalleteOpen = false;
|
|
}
|
|
}}
|
|
role="button"
|
|
tabindex="0">
|
|
<div class="relative p-2 border-b border-zinc-900/5 dark:border-zinc-100/5">
|
|
<div class="absolute top-1/2 translate-y-[calc(-50%-3px)] scale-105 left-5 w-6 h-6 text-[1.3rem] text-zinc-900 dark:text-zinc-400 text-opacity-40 pointer-events-none font-IconFamily">
|
|
{'\ueca5'}
|
|
</div>
|
|
<input
|
|
bind:this={searchbar}
|
|
bind:value={searchTerm}
|
|
onkeydown={handleKeyNav}
|
|
class="pr-4 pl-12 w-full h-10 text-lg bg-transparent border-0 outline-none placeholder-zinc-400 text-zinc-700 dark:placeholder-zinc-500 dark:text-white focus:ring-0 sm:text-xl"
|
|
placeholder="Search..."
|
|
/>
|
|
</div>
|
|
|
|
<Calculator
|
|
searchTerm={searchTerm}
|
|
isSelected={selectedIndex === 0}
|
|
on:hasResult={(e) => updateCalculatorState(e.detail)}
|
|
/>
|
|
|
|
{#if combinedResults.length > 0}
|
|
<ul class="overflow-y-auto max-h-[32rem] text-base scroll-py-2 p-1 gap-0.5 flex flex-col">
|
|
{#each combinedResults as result, i (result.id)}
|
|
{@const isSelected = selectedIndex === (calculatorResult ? i + 1 : i)}
|
|
{@const item = result.item}
|
|
<li>
|
|
{#if result.type === 'command'}
|
|
<!-- Render Static Command -->
|
|
{@const staticItem = item as StaticCommandItem}
|
|
<button
|
|
class="w-full flex items-center px-2 py-1.5 rounded-lg select-none cursor-pointer group
|
|
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
|
|
onclick={() => { staticItem.action(); commandPalleteOpen = false; }}
|
|
>
|
|
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{staticItem.icon}</div>
|
|
<span class="ml-4 text-lg truncate">
|
|
{@html highlightMatch(staticItem.text, searchTerm, result.matches)}
|
|
</span>
|
|
{#if staticItem.keybindLabel}
|
|
<span class="flex-none ml-auto text-sm font-semibold text-zinc-500 dark:text-zinc-400">
|
|
<kbd class="px-2 py-1 rounded bg-zinc-100 dark:bg-zinc-800">{staticItem.keybindLabel}</kbd>
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
{:else if result.type === 'dynamic'}
|
|
<!-- Render Dynamic Content Item -->
|
|
{@const dynamicItem = item as DynamicContentItem}
|
|
<button
|
|
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
|
|
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
|
|
onclick={() => { dynamicItem.action(); commandPalleteOpen = false; }}
|
|
>
|
|
<div class="flex items-center w-full">
|
|
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{dynamicItem.icon}</div>
|
|
<span class="ml-4 text-lg truncate">
|
|
{@html highlightMatch(dynamicItem.text, searchTerm, result.matches)}
|
|
</span>
|
|
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
|
|
{dynamicItem.category}
|
|
</span>
|
|
</div>
|
|
{#if dynamicItem.content}
|
|
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
|
|
{@html highlightSnippet(dynamicItem.content, searchTerm, result.matches)}
|
|
</div>
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else if !calculatorResult}
|
|
<div class="px-8 py-16 text-center text-zinc-900 dark:text-zinc-200 sm:px-16">
|
|
{#if isLoading}
|
|
<div class="mx-auto w-8 h-8 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
|
|
<p class="mt-4 text-lg dark:text-zinc-300">Searching...</p>
|
|
{:else}
|
|
<svg class="mx-auto w-8 h-8 text-opacity-40 dark:text-opacity-60" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
|
</svg>
|
|
<p class="mt-6 text-lg dark:text-zinc-300">No matches found. Try something else.</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
:global(.highlight) {
|
|
background-color: rgba(200, 200, 200, 0.3);
|
|
font-weight: 500;
|
|
border-radius: 2px;
|
|
padding: 0 2px;
|
|
margin: 0 -2px;
|
|
}
|
|
|
|
.dark :global(.highlight) {
|
|
background-color: rgba(79, 79, 79, 0.2);
|
|
}
|
|
</style> |