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-svelte": "^8.5.2",
|
||||
"flexsearch": "^0.8.147",
|
||||
"fuse.js": "^7.1.0",
|
||||
"idb": "^8.0.2",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -1,90 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { Index } from 'flexsearch';
|
||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
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 searchTerm = $state('');
|
||||
let index = $state(0);
|
||||
let selectedIndex = $state(0);
|
||||
let searchbar = $state<HTMLInputElement>();
|
||||
let filteredItems = $state<CommandItem[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
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;
|
||||
});
|
||||
let combinedResults = $state<CombinedResult[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let prevSearchTerm = $state('');
|
||||
|
||||
onMount(() => {
|
||||
setupSearchIndexes();
|
||||
|
||||
// @ts-ignore
|
||||
window.setCommandPalleteOpen = (open: boolean) => {
|
||||
commandPalleteOpen = open;
|
||||
@@ -102,19 +61,61 @@
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
if (index < filteredItems.length - 1) index++;
|
||||
if (selectedIndex < combinedResults.length - 1) selectedIndex++;
|
||||
};
|
||||
|
||||
const selectPrev = () => {
|
||||
if (index > 0) index--;
|
||||
if (selectedIndex > 0) selectedIndex--;
|
||||
};
|
||||
|
||||
const executeSelected = () => {
|
||||
filteredItems[index]?.action();
|
||||
combinedResults[selectedIndex]?.item.action();
|
||||
commandPalleteOpen = false;
|
||||
};
|
||||
|
||||
@@ -147,7 +148,7 @@
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<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 }}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -172,38 +173,86 @@
|
||||
/>
|
||||
</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">
|
||||
{#each filteredItems as item, i (item.text)}
|
||||
{#each combinedResults as result, i (result.id)}
|
||||
{@const isSelected = selectedIndex === i}
|
||||
{@const item = result.item}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center px-2 py-1.5 rounded-lg transition duration-150 select-none cursor-pointer group
|
||||
{i === index
|
||||
? '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={() => { item.action(); commandPalleteOpen = false; }}
|
||||
>
|
||||
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center
|
||||
{i === index
|
||||
? 'text-zinc-900 dark:text-white'
|
||||
: '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>
|
||||
</button>
|
||||
{#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 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={() => { 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 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>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div class="px-8 py-16 text-center text-zinc-900 dark:text-zinc-200 sm:px-16">
|
||||
<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 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}
|
||||
{/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 { BasePlugin } from '@/plugins/core/settings';
|
||||
import { booleanSetting, defineSettings, Setting, stringSetting } from '@/plugins/core/settingsHelpers';
|
||||
//import FlexSearch from 'flexsearch';
|
||||
import renderSvelte from '@/interface/main';
|
||||
import SearchBar from './SearchBar.svelte';
|
||||
import styles from './styles.css?inline';
|
||||
import { unmount } from 'svelte';
|
||||
import { loadDynamicItems, type DynamicContentItem } from './dynamicSearch';
|
||||
import { waitForElm } from '@/seqta/utils/waitForElm';
|
||||
|
||||
// Plugin settings
|
||||
const settings = defineSettings({
|
||||
searchHotkey: stringSetting({
|
||||
default: 'ctrl+k',
|
||||
@@ -18,7 +18,12 @@ const settings = defineSettings({
|
||||
default: true,
|
||||
title: 'Show 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> {
|
||||
@@ -27,10 +32,62 @@ class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
||||
|
||||
@Setting(settings.showRecentFirst)
|
||||
showRecentFirst!: boolean;
|
||||
|
||||
@Setting(settings.transparencyEffects)
|
||||
transparencyEffects!: boolean;
|
||||
}
|
||||
|
||||
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> = {
|
||||
id: 'global-search',
|
||||
name: 'Global Search',
|
||||
@@ -38,15 +95,19 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
version: '1.0.0',
|
||||
settings: settingsInstance.settings,
|
||||
disableToggle: true,
|
||||
|
||||
// Add some basic styles for our search UI
|
||||
styles: styles,
|
||||
|
||||
run: async (api) => {
|
||||
let app: any;
|
||||
|
||||
// Create search button
|
||||
api.seqta.onMount('#title', (titleElement) => {
|
||||
const dynamicData = createSampleDynamicData();
|
||||
loadDynamicItems(dynamicData);
|
||||
|
||||
const mountSearchBar = (titleElement: Element) => {
|
||||
if (titleElement.querySelector('.search-trigger')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create search button
|
||||
const searchButton = document.createElement('div');
|
||||
searchButton.className = 'search-trigger';
|
||||
@@ -68,17 +129,29 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
document.body.appendChild(searchRoot);
|
||||
const searchRootShadow = searchRoot.attachShadow({ mode: 'open' });
|
||||
|
||||
// Mount Svelte component in shadow DOM
|
||||
app = renderSvelte(SearchBar, searchRootShadow);
|
||||
|
||||
console.log('adding event listener to search button');
|
||||
// Handle click on search button
|
||||
searchButton.addEventListener('click', () => {
|
||||
console.log('search button clicked');
|
||||
// @ts-ignore
|
||||
window.setCommandPalleteOpen(true);
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
const searchButton = document.querySelector('.search-trigger');
|
||||
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;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
user-select: none;
|
||||
|
||||
svg {
|
||||
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