mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
feat: complete fuzzy search rebuild
This commit is contained in:
@@ -81,6 +81,7 @@
|
|||||||
"embla-carousel-autoplay": "^8.5.2",
|
"embla-carousel-autoplay": "^8.5.2",
|
||||||
"embla-carousel-svelte": "^8.5.2",
|
"embla-carousel-svelte": "^8.5.2",
|
||||||
"flexsearch": "^0.8.147",
|
"flexsearch": "^0.8.147",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"idb": "^8.0.2",
|
"idb": "^8.0.2",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
|||||||
@@ -1,90 +1,49 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { Index } from 'flexsearch';
|
|
||||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
import { fade, scale } from 'svelte/transition';
|
import { fade, scale } from 'svelte/transition';
|
||||||
import { quintOut } from 'svelte/easing';
|
import { 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';
|
||||||
|
|
||||||
|
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 commandPalleteOpen = $state(false);
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let index = $state(0);
|
let selectedIndex = $state(0);
|
||||||
let searchbar = $state<HTMLInputElement>();
|
let searchbar = $state<HTMLInputElement>();
|
||||||
let filteredItems = $state<CommandItem[]>([]);
|
let combinedResults = $state<CombinedResult[]>([]);
|
||||||
|
let isLoading = $state(false);
|
||||||
$effect(() => {
|
let prevSearchTerm = $state('');
|
||||||
if (!commandPalleteOpen) {
|
|
||||||
searchTerm = '';
|
|
||||||
index = 0;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
interface CommandItem {
|
|
||||||
icon: string;
|
|
||||||
category: string;
|
|
||||||
text: string;
|
|
||||||
keybind: string[];
|
|
||||||
keybindLabel: string;
|
|
||||||
action: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandItems: CommandItem[] = [
|
|
||||||
{
|
|
||||||
icon: '\uec95',
|
|
||||||
category: 'quick-action',
|
|
||||||
text: 'Save File',
|
|
||||||
keybind: ['ctrl+s'],
|
|
||||||
keybindLabel: '⌘S',
|
|
||||||
action: () => console.log('Save File')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '\ueadf',
|
|
||||||
category: 'quick-action',
|
|
||||||
text: 'Add New File',
|
|
||||||
keybind: ['ctrl+n'],
|
|
||||||
keybindLabel: '⌘N',
|
|
||||||
action: () => console.log('Add New File')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '\ueb10',
|
|
||||||
category: 'result',
|
|
||||||
text: 'Favourite Folder',
|
|
||||||
keybind: ['ctrl+o'],
|
|
||||||
keybindLabel: 'Open Folder',
|
|
||||||
action: () => console.log('Favourite Folder')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '\ueac4',
|
|
||||||
category: 'result',
|
|
||||||
text: 'Schoolwork',
|
|
||||||
keybind: ['ctrl+o'],
|
|
||||||
keybindLabel: 'Jump to...',
|
|
||||||
action: () => console.log('Open file')
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Create a FlexSearch index
|
|
||||||
const searchIndex = new Index({
|
|
||||||
tokenize: "forward",
|
|
||||||
preset: "score"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add command items to the index
|
|
||||||
commandItems.forEach((item, i) => {
|
|
||||||
searchIndex.add(i, item.text + " " + item.keybind.join(" "));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Replace reactive block with $effect
|
|
||||||
$effect(() => {
|
|
||||||
if (searchTerm.trim()) {
|
|
||||||
const results = searchIndex.search(searchTerm) as number[];
|
|
||||||
filteredItems = results.map(i => commandItems[i]);
|
|
||||||
} else {
|
|
||||||
filteredItems = commandItems;
|
|
||||||
}
|
|
||||||
index = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
setupSearchIndexes();
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.setCommandPalleteOpen = (open: boolean) => {
|
window.setCommandPalleteOpen = (open: boolean) => {
|
||||||
commandPalleteOpen = open;
|
commandPalleteOpen = open;
|
||||||
@@ -102,19 +61,61 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectNext = () => {
|
const selectNext = () => {
|
||||||
if (index < filteredItems.length - 1) index++;
|
if (selectedIndex < combinedResults.length - 1) selectedIndex++;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectPrev = () => {
|
const selectPrev = () => {
|
||||||
if (index > 0) index--;
|
if (selectedIndex > 0) selectedIndex--;
|
||||||
};
|
};
|
||||||
|
|
||||||
const executeSelected = () => {
|
const executeSelected = () => {
|
||||||
filteredItems[index]?.action();
|
combinedResults[selectedIndex]?.item.action();
|
||||||
commandPalleteOpen = false;
|
commandPalleteOpen = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,7 +148,7 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0">
|
tabindex="0">
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-2xl rounded-xl ring-1 shadow-2xl ring-black/5 dark:ring-white/10 { settingsState.transparencyEffects ? 'bg-white/80 dark:bg-zinc-900/80 backdrop-blur' : 'bg-white dark:bg-zinc-900' }"
|
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: 200, start: 0.95, opacity: 0, easing: quintOut }}
|
transition:scale={{ duration: 200, start: 0.95, opacity: 0, easing: quintOut }}
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -172,38 +173,86 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if filteredItems.length > 0}
|
<!-- Unified results list -->
|
||||||
|
{#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">
|
<ul class="overflow-y-auto max-h-[32rem] text-base scroll-py-2 p-1 gap-0.5 flex flex-col">
|
||||||
{#each filteredItems as item, i (item.text)}
|
{#each combinedResults as result, i (result.id)}
|
||||||
|
{@const isSelected = selectedIndex === i}
|
||||||
|
{@const item = result.item}
|
||||||
<li>
|
<li>
|
||||||
|
{#if result.type === 'command'}
|
||||||
|
<!-- Render Static Command -->
|
||||||
|
{@const staticItem = item as StaticCommandItem}
|
||||||
<button
|
<button
|
||||||
class="w-full flex items-center px-2 py-1.5 rounded-lg transition duration-150 select-none cursor-pointer group
|
class="w-full flex items-center px-2 py-1.5 rounded-lg transition duration-150 select-none cursor-pointer group
|
||||||
{i === index
|
{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'}"
|
||||||
? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white'
|
onclick={() => { staticItem.action(); commandPalleteOpen = false; }}
|
||||||
: 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
|
|
||||||
onclick={() => { item.action(); commandPalleteOpen = false; }}
|
|
||||||
>
|
>
|
||||||
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center
|
<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>
|
||||||
{i === index
|
<span class="ml-4 text-lg truncate">
|
||||||
? 'text-zinc-900 dark:text-white'
|
{@html highlightMatch(staticItem.text, searchTerm, result.matches)}
|
||||||
: 'text-zinc-600 dark:text-zinc-400'}">{item.icon}</div>
|
|
||||||
<span class="ml-4 text-lg truncate">{item.text}</span>
|
|
||||||
<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">{item.keybindLabel}</kbd>
|
|
||||||
</span>
|
</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>
|
</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 transition duration-150 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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="px-8 py-16 text-center text-zinc-900 dark:text-zinc-200 sm:px-16">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<p class="mt-6 text-lg dark:text-zinc-300">No matches found. Try something else.</p>
|
<p class="mt-6 text-lg dark:text-zinc-300">No matches found. Try something else.</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { loadHomePage } from '@/seqta/utils/Loaders/LoadHomePage';
|
||||||
|
|
||||||
|
export interface BaseCommandItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
category: string;
|
||||||
|
icon: string;
|
||||||
|
action: () => void;
|
||||||
|
keywords?: string[];
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaticCommandItem extends BaseCommandItem {
|
||||||
|
keybind?: string[];
|
||||||
|
keybindLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticCommands: StaticCommandItem[] = [
|
||||||
|
{
|
||||||
|
id: 'home',
|
||||||
|
icon: '\uea83',
|
||||||
|
category: 'navigation',
|
||||||
|
text: 'Home',
|
||||||
|
keybind: ['alt+h'],
|
||||||
|
keybindLabel: 'Alt+H',
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = '?page=/home';
|
||||||
|
loadHomePage();
|
||||||
|
},
|
||||||
|
priority: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'messages',
|
||||||
|
icon: '\uea6e',
|
||||||
|
category: 'navigation',
|
||||||
|
text: 'Messages',
|
||||||
|
keybind: ['alt+m'],
|
||||||
|
keybindLabel: 'Alt+M',
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = '?page=/messages';
|
||||||
|
},
|
||||||
|
priority: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'timetable',
|
||||||
|
icon: '\uecce',
|
||||||
|
category: 'navigation',
|
||||||
|
text: 'Timetable',
|
||||||
|
keybind: ['alt+t'],
|
||||||
|
keybindLabel: 'Alt+T',
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = '?page=/timetable';
|
||||||
|
},
|
||||||
|
priority: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'assessments',
|
||||||
|
icon: '\uebb3',
|
||||||
|
category: 'navigation',
|
||||||
|
text: 'Assessments',
|
||||||
|
keybind: ['alt+a'],
|
||||||
|
keybindLabel: 'Alt+A',
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = '?page=/assessments';
|
||||||
|
},
|
||||||
|
priority: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle-dark-mode',
|
||||||
|
icon: '\ueaa9',
|
||||||
|
category: 'action',
|
||||||
|
text: 'Toggle Dark Mode',
|
||||||
|
action: () => console.log('Toggle Dark Mode'),
|
||||||
|
priority: 5,
|
||||||
|
keywords: ['theme', 'appearance']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the predefined list of static commands.
|
||||||
|
*/
|
||||||
|
export const getStaticCommands = (): StaticCommandItem[] => {
|
||||||
|
return [...staticCommands];
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface DynamicContentItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
category: string;
|
||||||
|
icon: string;
|
||||||
|
action: () => void;
|
||||||
|
keywords?: string[];
|
||||||
|
contentType: 'message' | 'course' | 'assessment' | 'other';
|
||||||
|
content: string;
|
||||||
|
dateAdded: number;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dynamicItems: DynamicContentItem[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a new set of dynamic items.
|
||||||
|
*/
|
||||||
|
export const loadDynamicItems = (items: DynamicContentItem[]) => {
|
||||||
|
dynamicItems = [...items];
|
||||||
|
console.log(`Loaded ${items.length} dynamic items.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all currently loaded dynamic items.
|
||||||
|
*/
|
||||||
|
export const getAllDynamicItems = (): DynamicContentItem[] => {
|
||||||
|
return [...dynamicItems];
|
||||||
|
};
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { FuseResultMatch, MatchIndices } from './types';
|
||||||
|
|
||||||
|
export function highlightMatch(
|
||||||
|
text: string,
|
||||||
|
term: string,
|
||||||
|
matches?: readonly FuseResultMatch[]
|
||||||
|
): string {
|
||||||
|
if (!term.trim() || !matches || matches.length === 0) return text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find matches for the text field or allContent that contains the text
|
||||||
|
const fieldMatches = matches.find(match =>
|
||||||
|
match.key === 'text' ||
|
||||||
|
(match.key === 'allContent' && match.value?.includes(text))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fieldMatches || !fieldMatches.indices || fieldMatches.indices.length === 0) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of character positions to mark which ones need highlighting
|
||||||
|
const highlightMap = new Array(text.length).fill(false);
|
||||||
|
|
||||||
|
fieldMatches.indices.forEach((indices: MatchIndices) => {
|
||||||
|
const start = indices[0];
|
||||||
|
const end = indices[1];
|
||||||
|
|
||||||
|
if (fieldMatches.key === 'allContent') {
|
||||||
|
// Find where our text appears in the allContent
|
||||||
|
const allContent = fieldMatches.value;
|
||||||
|
const textPos = allContent?.indexOf(text) ?? -1;
|
||||||
|
|
||||||
|
// Only highlight if the match overlaps with our text
|
||||||
|
if (textPos >= 0) {
|
||||||
|
// Adjust start and end to be relative to our text field
|
||||||
|
const relStart = start - textPos;
|
||||||
|
const relEnd = end - textPos;
|
||||||
|
|
||||||
|
// Only highlight if the match actually overlaps with our text field
|
||||||
|
if (relEnd >= 0 && relStart < text.length) {
|
||||||
|
// Mark the overlapping characters
|
||||||
|
for (let i = Math.max(0, relStart); i <= Math.min(text.length - 1, relEnd); i++) {
|
||||||
|
highlightMap[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular text field match - ensure indices are within bounds
|
||||||
|
if (start >= 0 && end < text.length) {
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
highlightMap[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
let inHighlight = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (highlightMap[i] && !inHighlight) {
|
||||||
|
result += '<span class="highlight">';
|
||||||
|
inHighlight = true;
|
||||||
|
} else if (!highlightMap[i] && inHighlight) {
|
||||||
|
result += '</span>';
|
||||||
|
inHighlight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += text.charAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inHighlight) {
|
||||||
|
result += '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error highlighting match:', e);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to extract and highlight content snippet using Fuse matches
|
||||||
|
export function highlightSnippet(
|
||||||
|
content: string,
|
||||||
|
term: string,
|
||||||
|
matches?: readonly FuseResultMatch[]
|
||||||
|
): string {
|
||||||
|
if (!content || !term.trim() || !matches || matches.length === 0) return content;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find matches for content field or allContent that contains the content
|
||||||
|
const contentMatches = matches.find(match =>
|
||||||
|
match.key === 'content' ||
|
||||||
|
(match.key === 'allContent' && match.value?.includes(content))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!contentMatches || !contentMatches.indices || contentMatches.indices.length === 0) {
|
||||||
|
// No content matches, return plain content
|
||||||
|
return content.length > 100 ? content.substring(0, 100) + '...' : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the match indices
|
||||||
|
let allIndices: MatchIndices[] = contentMatches.indices as MatchIndices[];
|
||||||
|
|
||||||
|
// If matching against allContent, adjust indices to be relative to content
|
||||||
|
if (contentMatches.key === 'allContent') {
|
||||||
|
const allContent = contentMatches.value;
|
||||||
|
const contentPos = allContent?.indexOf(content) ?? -1;
|
||||||
|
|
||||||
|
if (contentPos >= 0) {
|
||||||
|
// Adjust indices to be relative to the content field
|
||||||
|
allIndices = allIndices
|
||||||
|
.map(indices => [indices[0] - contentPos, indices[1] - contentPos] as MatchIndices)
|
||||||
|
.filter(indices => indices[1] >= 0 && indices[0] < content.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allIndices.length === 0) {
|
||||||
|
return content.length > 100 ? content.substring(0, 100) + '...' : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a good center point for our snippet (average of first match)
|
||||||
|
const firstMatch = allIndices[0];
|
||||||
|
const matchCenter = Math.floor((firstMatch[0] + firstMatch[1]) / 2);
|
||||||
|
|
||||||
|
// Extract a window around the match
|
||||||
|
const windowSize = 100;
|
||||||
|
const start = Math.max(0, matchCenter - windowSize / 2);
|
||||||
|
const end = Math.min(content.length, matchCenter + windowSize / 2);
|
||||||
|
|
||||||
|
// Create the basic snippet
|
||||||
|
let snippet = content.substring(start, end);
|
||||||
|
if (start > 0) snippet = '...' + snippet;
|
||||||
|
if (end < content.length) snippet += '...';
|
||||||
|
|
||||||
|
// Create a highlighting map for the snippet
|
||||||
|
const snippetLength = snippet.length;
|
||||||
|
const highlightMap = new Array(snippetLength).fill(false);
|
||||||
|
|
||||||
|
// Calculate offset for the highlighting
|
||||||
|
const startOffset = start > 0 ? start - 3 : start; // Account for '...' if present
|
||||||
|
|
||||||
|
// Mark each matched character in the snippet
|
||||||
|
allIndices.forEach((indices: MatchIndices) => {
|
||||||
|
const matchStart = indices[0];
|
||||||
|
const matchEnd = indices[1];
|
||||||
|
|
||||||
|
// Skip matches outside our snippet window
|
||||||
|
if (matchEnd < start || matchStart > end) return;
|
||||||
|
|
||||||
|
// Adjust match indices to be relative to snippet
|
||||||
|
const snippetMatchStart = Math.max(0, matchStart - startOffset);
|
||||||
|
const snippetMatchEnd = Math.min(snippetLength - 1, matchEnd - startOffset);
|
||||||
|
|
||||||
|
// Mark characters for highlighting
|
||||||
|
for (let i = snippetMatchStart; i <= snippetMatchEnd; i++) {
|
||||||
|
if (i >= 0 && i < snippetLength) {
|
||||||
|
highlightMap[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the highlighted snippet
|
||||||
|
let result = '';
|
||||||
|
let inHighlight = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < snippetLength; i++) {
|
||||||
|
// If highlighting state changes, add appropriate tags
|
||||||
|
if (highlightMap[i] && !inHighlight) {
|
||||||
|
result += '<span class="highlight">';
|
||||||
|
inHighlight = true;
|
||||||
|
} else if (!highlightMap[i] && inHighlight) {
|
||||||
|
result += '</span>';
|
||||||
|
inHighlight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the current character
|
||||||
|
result += snippet.charAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close highlight tag if we're still in one at the end
|
||||||
|
if (inHighlight) {
|
||||||
|
result += '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error highlighting snippet:', e);
|
||||||
|
return content.length > 100 ? content.substring(0, 100) + '...' : content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { Plugin } from '@/plugins/core/types';
|
import type { Plugin } from '@/plugins/core/types';
|
||||||
import { BasePlugin } from '@/plugins/core/settings';
|
import { BasePlugin } from '@/plugins/core/settings';
|
||||||
import { booleanSetting, defineSettings, Setting, stringSetting } from '@/plugins/core/settingsHelpers';
|
import { booleanSetting, defineSettings, Setting, stringSetting } from '@/plugins/core/settingsHelpers';
|
||||||
//import FlexSearch from 'flexsearch';
|
|
||||||
import renderSvelte from '@/interface/main';
|
import renderSvelte from '@/interface/main';
|
||||||
import SearchBar from './SearchBar.svelte';
|
import SearchBar from './SearchBar.svelte';
|
||||||
import styles from './styles.css?inline';
|
import styles from './styles.css?inline';
|
||||||
import { unmount } from 'svelte';
|
import { unmount } from 'svelte';
|
||||||
|
import { loadDynamicItems, type DynamicContentItem } from './dynamicSearch';
|
||||||
|
import { waitForElm } from '@/seqta/utils/waitForElm';
|
||||||
|
|
||||||
// Plugin settings
|
|
||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
searchHotkey: stringSetting({
|
searchHotkey: stringSetting({
|
||||||
default: 'ctrl+k',
|
default: 'ctrl+k',
|
||||||
@@ -18,7 +18,12 @@ const settings = defineSettings({
|
|||||||
default: true,
|
default: true,
|
||||||
title: 'Show Recent First',
|
title: 'Show Recent First',
|
||||||
description: 'Sort dynamic content by most recent first',
|
description: 'Sort dynamic content by most recent first',
|
||||||
})
|
}),
|
||||||
|
transparencyEffects: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: 'Transparency Effects',
|
||||||
|
description: 'Enable transparency effects for the search bar',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
||||||
@@ -27,10 +32,62 @@ class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
|||||||
|
|
||||||
@Setting(settings.showRecentFirst)
|
@Setting(settings.showRecentFirst)
|
||||||
showRecentFirst!: boolean;
|
showRecentFirst!: boolean;
|
||||||
|
|
||||||
|
@Setting(settings.transparencyEffects)
|
||||||
|
transparencyEffects!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsInstance = new GlobalSearchPlugin();
|
const settingsInstance = new GlobalSearchPlugin();
|
||||||
|
|
||||||
|
const createSampleDynamicData = (): DynamicContentItem[] => {
|
||||||
|
const sampleMessages = [
|
||||||
|
{
|
||||||
|
id: 'message_1',
|
||||||
|
text: 'Assignment Discussion',
|
||||||
|
category: 'messages',
|
||||||
|
contentType: 'message' as const,
|
||||||
|
icon: '\uea6e',
|
||||||
|
content: 'Hey everyone, I was wondering if anyone could help me with the Physics assignment on circular motion. I\'m stuck on question 3 about centripetal force.',
|
||||||
|
dateAdded: Date.now() - 1000 * 60 * 60 * 2,
|
||||||
|
action: () => console.log('Open message 1'),
|
||||||
|
keywords: ['John Smith', 'message', 'chat'],
|
||||||
|
metadata: { author: 'John Smith'}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const sampleCourses = [
|
||||||
|
{
|
||||||
|
id: 'course_1',
|
||||||
|
text: 'Physics 101',
|
||||||
|
category: 'courses',
|
||||||
|
contentType: 'course' as const,
|
||||||
|
icon: '\uea67',
|
||||||
|
content: 'An introduction to mechanics, thermodynamics, and wave phenomena.',
|
||||||
|
dateAdded: Date.now() - 1000 * 60 * 60 * 24 * 5, // 5 days ago
|
||||||
|
action: () => console.log('Open Physics course'),
|
||||||
|
keywords: ['Dr. Richard Feynman', 'course', 'class'],
|
||||||
|
metadata: { teacher: 'Dr. Richard Feynman' }
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const sampleAssessments = [
|
||||||
|
{
|
||||||
|
id: 'assessment_1',
|
||||||
|
text: 'Physics Lab Report',
|
||||||
|
category: 'assessments',
|
||||||
|
contentType: 'assessment' as const,
|
||||||
|
icon: '\uebb3',
|
||||||
|
content: 'Complete a lab report on the pendulum experiment.',
|
||||||
|
dateAdded: Date.now() - 1000 * 60 * 60 * 24, // 1 day ago
|
||||||
|
action: () => console.log('Open Physics assessment'),
|
||||||
|
keywords: ['Physics 101', 'assessment', 'homework'],
|
||||||
|
metadata: { dueDate: Date.now() + 1000 * 60 * 60 * 24 * 3 }
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return [...sampleMessages, ...sampleCourses, ...sampleAssessments];
|
||||||
|
};
|
||||||
|
|
||||||
const globalSearchPlugin: Plugin<typeof settings> = {
|
const globalSearchPlugin: Plugin<typeof settings> = {
|
||||||
id: 'global-search',
|
id: 'global-search',
|
||||||
name: 'Global Search',
|
name: 'Global Search',
|
||||||
@@ -38,15 +95,19 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
settings: settingsInstance.settings,
|
settings: settingsInstance.settings,
|
||||||
disableToggle: true,
|
disableToggle: true,
|
||||||
|
|
||||||
// Add some basic styles for our search UI
|
|
||||||
styles: styles,
|
styles: styles,
|
||||||
|
|
||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
let app: any;
|
let app: any;
|
||||||
|
|
||||||
// Create search button
|
const dynamicData = createSampleDynamicData();
|
||||||
api.seqta.onMount('#title', (titleElement) => {
|
loadDynamicItems(dynamicData);
|
||||||
|
|
||||||
|
const mountSearchBar = (titleElement: Element) => {
|
||||||
|
if (titleElement.querySelector('.search-trigger')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Create search button
|
// Create search button
|
||||||
const searchButton = document.createElement('div');
|
const searchButton = document.createElement('div');
|
||||||
searchButton.className = 'search-trigger';
|
searchButton.className = 'search-trigger';
|
||||||
@@ -68,17 +129,29 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
document.body.appendChild(searchRoot);
|
document.body.appendChild(searchRoot);
|
||||||
const searchRootShadow = searchRoot.attachShadow({ mode: 'open' });
|
const searchRootShadow = searchRoot.attachShadow({ mode: 'open' });
|
||||||
|
|
||||||
// Mount Svelte component in shadow DOM
|
console.log('adding event listener to search button');
|
||||||
app = renderSvelte(SearchBar, searchRootShadow);
|
|
||||||
|
|
||||||
// Handle click on search button
|
// Handle click on search button
|
||||||
searchButton.addEventListener('click', () => {
|
searchButton.addEventListener('click', () => {
|
||||||
|
console.log('search button clicked');
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.setCommandPalleteOpen(true);
|
window.setCommandPalleteOpen(true);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up
|
// Mount Svelte component in shadow DOM
|
||||||
|
app = renderSvelte(SearchBar, searchRootShadow, {
|
||||||
|
transparencyEffects: api.settings.transparencyEffects,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = document.querySelector('#title');
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
mountSearchBar(title);
|
||||||
|
} else {
|
||||||
|
await waitForElm('#title', true, 100, 60);
|
||||||
|
mountSearchBar(document.querySelector('#title') as Element);
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const searchButton = document.querySelector('.search-trigger');
|
const searchButton = document.querySelector('.search-trigger');
|
||||||
const searchRoot = document.querySelector('.global-search-root');
|
const searchRoot = document.querySelector('.global-search-root');
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import Fuse, { type FuseResult } from 'fuse.js';
|
||||||
|
import { getStaticCommands, type StaticCommandItem } from './commands';
|
||||||
|
import { getAllDynamicItems, type DynamicContentItem } from './dynamicSearch';
|
||||||
|
import type { CombinedResult } from './types';
|
||||||
|
|
||||||
|
export function prepareDynamicItems(items: DynamicContentItem[]): DynamicContentItem[] {
|
||||||
|
return items.map(item => {
|
||||||
|
const preparedItem = { ...item };
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
preparedItem.allContent = [
|
||||||
|
item.text,
|
||||||
|
item.content,
|
||||||
|
item.category,
|
||||||
|
item.keywords?.join(' ') || '',
|
||||||
|
...Object.values(item.metadata || {})
|
||||||
|
.filter(value => typeof value === 'string')
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return preparedItem;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSearchIndexes() {
|
||||||
|
const commands = getStaticCommands();
|
||||||
|
const dynamicItems = prepareDynamicItems(getAllDynamicItems());
|
||||||
|
|
||||||
|
const commandOptions = {
|
||||||
|
keys: ['text', 'category', 'keywords'],
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
|
threshold: 0.4,
|
||||||
|
minMatchCharLength: 2,
|
||||||
|
ignoreLocation: true,
|
||||||
|
useExtendedSearch: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const dynamicOptions = {
|
||||||
|
keys: [
|
||||||
|
'text',
|
||||||
|
'content',
|
||||||
|
'category',
|
||||||
|
'keywords',
|
||||||
|
'allContent'
|
||||||
|
],
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
|
threshold: 0.4,
|
||||||
|
minMatchCharLength: 2,
|
||||||
|
ignoreLocation: true,
|
||||||
|
useExtendedSearch: true
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
|
||||||
|
dynamicContentFuse: new Fuse(dynamicItems, dynamicOptions) as Fuse<DynamicContentItem>,
|
||||||
|
commands,
|
||||||
|
dynamicItems
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchCommands(
|
||||||
|
commandsFuse: Fuse<StaticCommandItem>,
|
||||||
|
query: string,
|
||||||
|
commandIdToItemMap: Map<string, StaticCommandItem>,
|
||||||
|
limit = 10
|
||||||
|
): CombinedResult[] {
|
||||||
|
if (!commandsFuse) return [];
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
return Array.from(commandIdToItemMap.values())
|
||||||
|
.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
type: 'command' as const,
|
||||||
|
score: 100 + (item.priority ?? 0),
|
||||||
|
item
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResults = commandsFuse.search(query, { limit });
|
||||||
|
|
||||||
|
return searchResults.map((result: FuseResult<StaticCommandItem>) => {
|
||||||
|
const item = result.item;
|
||||||
|
const fuseScore = 15 * (1 - (result.score || 0.5));
|
||||||
|
const score = fuseScore + (item.priority ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
type: 'command' as const,
|
||||||
|
score,
|
||||||
|
item,
|
||||||
|
matches: result.matches
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchDynamicItems(
|
||||||
|
dynamicContentFuse: Fuse<DynamicContentItem>,
|
||||||
|
query: string,
|
||||||
|
dynamicIdToItemMap: Map<string, DynamicContentItem>,
|
||||||
|
limit = 10
|
||||||
|
): CombinedResult[] {
|
||||||
|
if (!dynamicContentFuse) return [];
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
return Array.from(dynamicIdToItemMap.values())
|
||||||
|
.sort((a, b) => b.dateAdded - a.dateAdded)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
type: 'dynamic' as const,
|
||||||
|
score: 80,
|
||||||
|
item
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const searchResults = dynamicContentFuse.search(query, { limit });
|
||||||
|
|
||||||
|
return searchResults.map((result: FuseResult<DynamicContentItem>) => {
|
||||||
|
const item = result.item;
|
||||||
|
const fuseScore = 10 * (1 - (result.score || 0.5));
|
||||||
|
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
||||||
|
const recencyBoost = 1 / (ageInDays + 1);
|
||||||
|
const score = fuseScore + recencyBoost + (item.priority ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
type: 'dynamic' as const,
|
||||||
|
score,
|
||||||
|
item,
|
||||||
|
matches: result.matches
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function performSearch(
|
||||||
|
query: string,
|
||||||
|
commandsFuse: Fuse<StaticCommandItem>,
|
||||||
|
dynamicContentFuse: Fuse<DynamicContentItem>,
|
||||||
|
commandIdToItemMap: Map<string, StaticCommandItem>,
|
||||||
|
dynamicIdToItemMap: Map<string, DynamicContentItem>
|
||||||
|
): CombinedResult[] {
|
||||||
|
const commandResults = searchCommands(commandsFuse, query, commandIdToItemMap);
|
||||||
|
const dynamicResults = searchDynamicItems(dynamicContentFuse, query, dynamicIdToItemMap);
|
||||||
|
|
||||||
|
const results = [...commandResults, ...dynamicResults];
|
||||||
|
results.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
padding: 3px 12px;
|
padding: 3px 12px;
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { StaticCommandItem } from './commands';
|
||||||
|
import type { DynamicContentItem } from './dynamicSearch';
|
||||||
|
|
||||||
|
export interface MatchIndices {
|
||||||
|
readonly 0: number;
|
||||||
|
readonly 1: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FuseResultMatch {
|
||||||
|
key?: string;
|
||||||
|
value?: string;
|
||||||
|
indices: readonly MatchIndices[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombinedResult {
|
||||||
|
id: string;
|
||||||
|
type: 'command' | 'dynamic';
|
||||||
|
score: number;
|
||||||
|
item: StaticCommandItem | DynamicContentItem;
|
||||||
|
matches?: readonly FuseResultMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FuseResult<T> {
|
||||||
|
item: T;
|
||||||
|
refIndex: number;
|
||||||
|
score?: number;
|
||||||
|
matches?: readonly FuseResultMatch[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user