feat: improved hotkey support and controls

This commit is contained in:
SethBurkart123
2025-05-25 18:15:06 +10:00
parent f66340cb63
commit 991f80d316
10 changed files with 570 additions and 112 deletions
@@ -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;
}
}