mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 19:54:39 +00:00
feat: improved hotkey support and controls
This commit is contained in:
@@ -13,15 +13,22 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import { renderComponentMap } from '../indexing/renderComponents';
|
||||
import HighlightedText from '../utils/HighlightedText.svelte';
|
||||
import { matchesHotkey } from '../utils/hotkeyUtils';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
const {
|
||||
transparencyEffects,
|
||||
showRecentFirst
|
||||
showRecentFirst,
|
||||
searchHotkey: initialSearchHotkey
|
||||
} = $props<{
|
||||
transparencyEffects: boolean,
|
||||
showRecentFirst: boolean
|
||||
showRecentFirst: boolean,
|
||||
searchHotkey: string
|
||||
}>();
|
||||
|
||||
// Make searchHotkey reactive to setting changes
|
||||
let currentSearchHotkey = $state(initialSearchHotkey);
|
||||
|
||||
let commandsFuse = $state<Fuse<StaticCommandItem>>();
|
||||
let dynamicContentFuse = $state<Fuse<IndexItem>>();
|
||||
|
||||
@@ -32,6 +39,78 @@
|
||||
let completedJobs = $state(0);
|
||||
let totalJobs = $state(0);
|
||||
|
||||
let commandPalleteOpen = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let selectedIndex = $state(0);
|
||||
let combinedResults = $state<CombinedResult[]>([]);
|
||||
let searchbar = $state<HTMLInputElement>();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let calculatorResult = $state<string | null>(null);
|
||||
let resultsList = $state<HTMLUListElement>();
|
||||
|
||||
const updateCalculatorState = (hasResult: string | null) => {
|
||||
calculatorResult = hasResult;
|
||||
};
|
||||
|
||||
let keydownHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
// Listen for setting changes
|
||||
$effect(() => {
|
||||
const loadSettings = async () => {
|
||||
const settings = await browser.storage.local.get('plugin.global-search.settings');
|
||||
const pluginSettings = settings['plugin.global-search.settings'] as { searchHotkey?: string } | undefined;
|
||||
if (pluginSettings?.searchHotkey) {
|
||||
currentSearchHotkey = pluginSettings.searchHotkey;
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
|
||||
// Listen for storage changes
|
||||
const handleStorageChange = (changes: any, area: string) => {
|
||||
if (area === 'local' && changes['plugin.global-search.settings']) {
|
||||
const newSettings = changes['plugin.global-search.settings'].newValue as { searchHotkey?: string } | undefined;
|
||||
if (newSettings?.searchHotkey) {
|
||||
currentSearchHotkey = newSettings.searchHotkey;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
browser.storage.onChanged.addListener(handleStorageChange);
|
||||
|
||||
return () => {
|
||||
browser.storage.onChanged.removeListener(handleStorageChange);
|
||||
};
|
||||
});
|
||||
|
||||
// Update keydown handler when hotkey changes
|
||||
$effect(() => {
|
||||
if (keydownHandler) {
|
||||
window.removeEventListener('keydown', keydownHandler);
|
||||
}
|
||||
|
||||
keydownHandler = (e: KeyboardEvent) => {
|
||||
if (matchesHotkey(e, currentSearchHotkey)) {
|
||||
e.preventDefault();
|
||||
commandPalleteOpen = true;
|
||||
tick().then(() => searchbar?.focus());
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
commandPalleteOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', keydownHandler);
|
||||
|
||||
return () => {
|
||||
if (keydownHandler) {
|
||||
window.removeEventListener('keydown', keydownHandler);
|
||||
keydownHandler = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const progressHandler = (event: CustomEvent) => {
|
||||
const { completed, total, indexing } = event.detail;
|
||||
@@ -48,7 +127,14 @@
|
||||
performSearch();
|
||||
};
|
||||
window.addEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||
|
||||
setupSearchIndexes();
|
||||
|
||||
// @ts-ignore - Intentionally adding to window
|
||||
window.setCommandPalleteOpen = (open: boolean) => {
|
||||
commandPalleteOpen = open;
|
||||
};
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('indexing-progress', progressHandler as EventListener);
|
||||
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||
@@ -69,43 +155,6 @@
|
||||
|
||||
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 calculatorResult = $state<string | null>(null);
|
||||
let resultsList = $state<HTMLUListElement>();
|
||||
|
||||
const updateCalculatorState = (hasResult: string | null) => {
|
||||
calculatorResult = hasResult;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
setupSearchIndexes();
|
||||
|
||||
// @ts-ignore - Intentionally adding to window
|
||||
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 = async () => {
|
||||
isLoading = true;
|
||||
@@ -201,6 +250,7 @@
|
||||
};
|
||||
|
||||
const handleKeyNav = (e: KeyboardEvent) => {
|
||||
// Handle regular navigation
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectNext();
|
||||
@@ -281,11 +331,6 @@
|
||||
<span class="ml-4 text-lg truncate">
|
||||
<HighlightedText text={staticItem.text} term={searchTerm} matches={result.matches} />
|
||||
</span>
|
||||
{#if staticItem.keybindLabel}
|
||||
<div class="flex-none ml-auto">
|
||||
{@render Shortcut({ text: '', keybind: staticItem.keybindLabel })}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if result.type === 'dynamic'}
|
||||
{@const dynamicItem = item as IndexItem}
|
||||
@@ -344,38 +389,26 @@
|
||||
{#if combinedResults.length > 0 || calculatorResult}
|
||||
<div class="flex justify-between items-center h-5 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<div class="flex gap-4 items-center">
|
||||
{#if !calculatorResult}
|
||||
{#if selectedIndex >= 0 && selectedIndex < combinedResults.length}
|
||||
{@const item = combinedResults[selectedIndex].item}
|
||||
{#if 'keybind' in item && item.keybind}
|
||||
{@render Shortcut({ text: 'Shortcut', keybind: [ ...(item?.keybindLabel ?? []) ] })}
|
||||
{/if}
|
||||
{/if}
|
||||
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
|
||||
{#if calculatorResult && selectedIndex === 0}
|
||||
{@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
|
||||
{:else}
|
||||
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex gap-4 items-center">
|
||||
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
|
||||
{#if calculatorResult && selectedIndex === 0}
|
||||
{@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
|
||||
{:else}
|
||||
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
|
||||
{/if}
|
||||
</div>
|
||||
{#if isIndexing}
|
||||
<div class="inset-x-0 top-0">
|
||||
<div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
Indexing
|
||||
</div>
|
||||
<div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all duration-300 ease-out"
|
||||
style="width: {(completedJobs / totalJobs) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if isIndexing}
|
||||
<div class="inset-x-0 top-0">
|
||||
<div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
Indexing
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all duration-300 ease-out"
|
||||
style="width: {(completedJobs / totalJobs) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,6 @@ const staticCommands: StaticCommandItem[] = [
|
||||
icon: "\ueb4c",
|
||||
category: "navigation",
|
||||
text: "Home",
|
||||
keybind: ["alt+h"],
|
||||
keybindLabel: ["Alt", "H"],
|
||||
action: () => {
|
||||
window.location.hash = "?page=/home";
|
||||
loadHomePage();
|
||||
@@ -35,8 +33,6 @@ const staticCommands: StaticCommandItem[] = [
|
||||
icon: "\uebfd",
|
||||
category: "navigation",
|
||||
text: "Direct Messages",
|
||||
keybind: ["alt+m"],
|
||||
keybindLabel: ["Alt", "M"],
|
||||
action: () => {
|
||||
window.location.hash = "?page=/messages";
|
||||
},
|
||||
@@ -47,8 +43,6 @@ const staticCommands: StaticCommandItem[] = [
|
||||
icon: "\ue9cd",
|
||||
category: "navigation",
|
||||
text: "Timetable",
|
||||
keybind: ["alt+t"],
|
||||
keybindLabel: ["Alt", "T"],
|
||||
action: () => {
|
||||
window.location.hash = "?page=/timetable";
|
||||
},
|
||||
@@ -59,10 +53,8 @@ const staticCommands: StaticCommandItem[] = [
|
||||
icon: "\ueac3",
|
||||
category: "navigation",
|
||||
text: "Assessments",
|
||||
keybind: ["alt+a"],
|
||||
keybindLabel: ["Alt", "A"],
|
||||
action: () => {
|
||||
window.location.hash = "?page=/assessments";
|
||||
window.location.hash = "?page=/assessments/upcoming";
|
||||
},
|
||||
priority: 4,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
buttonSetting,
|
||||
defineSettings,
|
||||
Setting,
|
||||
stringSetting,
|
||||
hotkeySetting,
|
||||
} from "@/plugins/core/settingsHelpers";
|
||||
import styles from "./styles.css?inline";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
@@ -14,11 +14,17 @@ import { initVectorSearch } from "../search/vector/vectorSearch";
|
||||
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
|
||||
import { IndexedDbManager } from "embeddia";
|
||||
|
||||
// Platform-aware default hotkey
|
||||
const getDefaultHotkey = () => {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
return isMac ? "cmd+k" : "ctrl+k";
|
||||
};
|
||||
|
||||
const settings = defineSettings({
|
||||
searchHotkey: stringSetting({
|
||||
default: "ctrl+k",
|
||||
searchHotkey: hotkeySetting({
|
||||
default: getDefaultHotkey(),
|
||||
title: "Search Hotkey",
|
||||
description: "Keyboard shortcut to open the search (cmd on Mac)",
|
||||
description: "Keyboard shortcut to open the search",
|
||||
}),
|
||||
showRecentFirst: booleanSetting({
|
||||
default: true,
|
||||
@@ -99,10 +105,15 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
run: async (api) => {
|
||||
const appRef = { current: null };
|
||||
|
||||
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
|
||||
primaryKey: "id",
|
||||
autoIncrement: false,
|
||||
});
|
||||
try {
|
||||
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
|
||||
primaryKey: "id",
|
||||
autoIncrement: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create IndexedDB:", error);
|
||||
// Continue execution - the search might still work without persistence
|
||||
}
|
||||
|
||||
initVectorSearch();
|
||||
|
||||
@@ -117,8 +128,8 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
if (title) {
|
||||
mountSearchBar(title, api, appRef);
|
||||
} else {
|
||||
await waitForElm("#title", true, 100, 60);
|
||||
mountSearchBar(document.querySelector("#title") as Element, api, appRef);
|
||||
const titleElement = await waitForElm("#title", true, 100, 60);
|
||||
mountSearchBar(titleElement, api, appRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import renderSvelte from "@/interface/main";
|
||||
import SearchBar from "../components/SearchBar.svelte";
|
||||
import { unmount } from "svelte";
|
||||
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
||||
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
export function mountSearchBar(
|
||||
titleElement: Element,
|
||||
@@ -12,19 +14,41 @@ export function mountSearchBar(
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to default hotkey if the current one is invalid
|
||||
let currentHotkey = isValidHotkey(api.settings.searchHotkey) ? api.settings.searchHotkey : "ctrl+k";
|
||||
let hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
|
||||
|
||||
const searchButton = document.createElement("div");
|
||||
searchButton.className = "search-trigger";
|
||||
searchButton.innerHTML = /* html */ `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<p>Quick search...</p>
|
||||
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">⌘K</span>
|
||||
`;
|
||||
|
||||
const updateSearchButtonDisplay = () => {
|
||||
searchButton.innerHTML = /* html */ `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<p>Quick search...</p>
|
||||
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">${hotkeyDisplay}</span>
|
||||
`;
|
||||
};
|
||||
|
||||
updateSearchButtonDisplay();
|
||||
titleElement.appendChild(searchButton);
|
||||
|
||||
// Listen for hotkey setting changes
|
||||
const handleStorageChange = (changes: any, area: string) => {
|
||||
if (area === 'local' && changes['plugin.global-search.settings']) {
|
||||
const newSettings = changes['plugin.global-search.settings'].newValue as { searchHotkey?: string } | undefined;
|
||||
if (newSettings?.searchHotkey && isValidHotkey(newSettings.searchHotkey)) {
|
||||
currentHotkey = newSettings.searchHotkey;
|
||||
hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
|
||||
updateSearchButtonDisplay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
browser.storage.onChanged.addListener(handleStorageChange);
|
||||
|
||||
const searchRoot = document.createElement("div");
|
||||
document.body.appendChild(searchRoot);
|
||||
const searchRootShadow = searchRoot.attachShadow({ mode: "open" });
|
||||
@@ -38,6 +62,7 @@ export function mountSearchBar(
|
||||
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
||||
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
||||
showRecentFirst: api.settings.showRecentFirst,
|
||||
searchHotkey: currentHotkey,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error rendering Svelte component:", error);
|
||||
@@ -45,12 +70,30 @@ export function mountSearchBar(
|
||||
}
|
||||
|
||||
export function cleanupSearchBar(appRef: { current: any }) {
|
||||
const searchButton = document.querySelector(".search-trigger");
|
||||
const searchRoot = document.querySelector(".global-search-root");
|
||||
if (searchButton) searchButton.remove();
|
||||
if (searchRoot) searchRoot.remove();
|
||||
if (appRef.current) {
|
||||
try {
|
||||
unmount(appRef.current);
|
||||
appRef.current = null;
|
||||
} catch (error) {
|
||||
console.error("Error unmounting Svelte component:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up workers
|
||||
// Remove search trigger button
|
||||
const searchTrigger = document.querySelector(".search-trigger");
|
||||
if (searchTrigger) {
|
||||
searchTrigger.remove();
|
||||
}
|
||||
|
||||
// Remove search root
|
||||
const searchRoot = document.querySelector("div[data-search-root]");
|
||||
if (searchRoot) {
|
||||
searchRoot.remove();
|
||||
}
|
||||
|
||||
// Clean up vector worker
|
||||
VectorWorkerManager.getInstance().terminate();
|
||||
unmount(appRef.current);
|
||||
|
||||
// Remove storage listener
|
||||
browser.storage.onChanged.removeListener(() => {});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
export interface ParsedHotkey {
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
alt: boolean;
|
||||
shift: boolean;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export function parseHotkey(hotkeyString: string): ParsedHotkey {
|
||||
const parts = hotkeyString.toLowerCase().split('+').map(part => part.trim()).filter(part => part.length > 0);
|
||||
|
||||
const parsed: ParsedHotkey = {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
key: ''
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
switch (part) {
|
||||
case 'ctrl':
|
||||
case 'control':
|
||||
parsed.ctrl = true;
|
||||
break;
|
||||
case 'cmd':
|
||||
case 'meta':
|
||||
case 'command':
|
||||
parsed.meta = true;
|
||||
break;
|
||||
case 'alt':
|
||||
case 'option':
|
||||
parsed.alt = true;
|
||||
break;
|
||||
case 'shift':
|
||||
parsed.shift = true;
|
||||
break;
|
||||
default:
|
||||
// This should be the key - take the last non-modifier as the key
|
||||
if (part.length > 0) {
|
||||
parsed.key = part;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function formatHotkeyForDisplay(hotkeyString: string): string {
|
||||
try {
|
||||
const parsed = parseHotkey(hotkeyString);
|
||||
const parts: string[] = [];
|
||||
|
||||
// Detect platform
|
||||
const isMac = (navigator.platform.toUpperCase().indexOf('MAC') >= 0);
|
||||
|
||||
if (parsed.ctrl) {
|
||||
parts.push('Ctrl');
|
||||
}
|
||||
if (parsed.meta) {
|
||||
parts.push('⌘');
|
||||
}
|
||||
if (parsed.alt) {
|
||||
parts.push(isMac ? '⌥' : 'Alt');
|
||||
}
|
||||
if (parsed.shift) {
|
||||
parts.push(isMac ? '⇧' : 'Shift');
|
||||
}
|
||||
|
||||
if (parsed.key) {
|
||||
parts.push(parsed.key.toUpperCase());
|
||||
}
|
||||
|
||||
return parts.join(isMac ? ' ' : '+');
|
||||
} catch (error) {
|
||||
console.warn('Invalid hotkey string:', hotkeyString);
|
||||
return hotkeyString; // Fallback to original string
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesHotkey(event: KeyboardEvent, hotkeyString: string): boolean {
|
||||
try {
|
||||
const parsed = parseHotkey(hotkeyString);
|
||||
|
||||
// If no key is specified, don't match anything
|
||||
if (!parsed.key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check modifiers
|
||||
if (parsed.ctrl && !event.ctrlKey) return false;
|
||||
if (parsed.meta && !event.metaKey) return false;
|
||||
if (parsed.alt && !event.altKey) return false;
|
||||
if (parsed.shift && !event.shiftKey) return false;
|
||||
|
||||
// Check if we have extra modifiers that shouldn't be there
|
||||
if (!parsed.ctrl && event.ctrlKey) return false;
|
||||
if (!parsed.meta && event.metaKey) return false;
|
||||
if (!parsed.alt && event.altKey) return false;
|
||||
if (!parsed.shift && event.shiftKey) return false;
|
||||
|
||||
// Check the key
|
||||
return event.key.toLowerCase() === parsed.key.toLowerCase();
|
||||
} catch (error) {
|
||||
console.warn('Error matching hotkey:', hotkeyString, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidHotkey(hotkeyString: string): boolean {
|
||||
try {
|
||||
const parsed = parseHotkey(hotkeyString);
|
||||
return parsed.key.length > 0;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user