diff --git a/src/interface/components/HotkeyInput.svelte b/src/interface/components/HotkeyInput.svelte new file mode 100644 index 00000000..f575259c --- /dev/null +++ b/src/interface/components/HotkeyInput.svelte @@ -0,0 +1,229 @@ + + +
+
+ {#if isRecording} + +
+ {#if hotkeyParts.length === 0} + Press keys... + {/if} +
+ {:else if hotkeyParts.length > 0} + +
+ {#each hotkeyParts as part} +
+ {part} +
+ {/each} +
+ {:else} + +
+ Click to set +
+ {/if} + + + +
+
+ + \ No newline at end of file diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 76efb822..acf1651d 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -3,6 +3,7 @@ import Button from "../../components/Button.svelte" import Slider from "../../components/Slider.svelte" import Select from "@/interface/components/Select.svelte" + import HotkeyInput from "@/interface/components/HotkeyInput.svelte" import browser from "webextension-polyfill" @@ -12,7 +13,7 @@ import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent" import { getAllPluginSettings } from "@/plugins" - import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting } from "@/plugins/core/types" + import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting } from "@/plugins/core/types" // Union type representing all possible settings type SettingType = @@ -27,6 +28,10 @@ (Omit & { type: 'button', id: string + }) | + (Omit & { + type: 'hotkey', + id: string }); interface Plugin { @@ -195,10 +200,10 @@ {#if (plugin as any).disableToggle}
-

+

Enable {plugin.name} {#if plugin.beta} - + Beta {/if} @@ -258,6 +263,11 @@ onClick={() => setting.trigger?.()} text={setting.title} /> + {:else if setting.type === 'hotkey'} + updatePluginSetting(plugin.pluginId, key, value)} + /> {/if}

diff --git a/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte b/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte index c0acdd89..1be0b469 100644 --- a/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte +++ b/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte @@ -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>(); let dynamicContentFuse = $state>(); @@ -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([]); + let searchbar = $state(); + + let isLoading = $state(false); + let calculatorResult = $state(null); + let resultsList = $state(); + + 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(); - let combinedResults = $state([]); - let isLoading = $state(false); - let calculatorResult = $state(null); - let resultsList = $state(); - - 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 @@ - {#if staticItem.keybindLabel} -
- {@render Shortcut({ text: '', keybind: staticItem.keybindLabel })} -
- {/if} {:else if result.type === 'dynamic'} {@const dynamicItem = item as IndexItem} @@ -344,38 +389,26 @@ {#if combinedResults.length > 0 || calculatorResult}
- {#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}
-
-
- {@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})} - {#if calculatorResult && selectedIndex === 0} - {@render Shortcut({ text: 'Copy result', keybind: ['↵']})} - {:else} - {@render Shortcut({ text: 'Select', keybind: ['↵']})} - {/if} -
- {#if isIndexing} -
-
- Indexing -
-
-
-
+ {#if isIndexing} +
+
+ Indexing
- {/if} -
+
+
+
+
+ {/if}
{/if}
diff --git a/src/plugins/built-in/globalSearch/src/core/commands.ts b/src/plugins/built-in/globalSearch/src/core/commands.ts index ca7d39c1..2b04bb39 100644 --- a/src/plugins/built-in/globalSearch/src/core/commands.ts +++ b/src/plugins/built-in/globalSearch/src/core/commands.ts @@ -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, }, diff --git a/src/plugins/built-in/globalSearch/src/core/index.ts b/src/plugins/built-in/globalSearch/src/core/index.ts index 3c26c524..ab04a008 100644 --- a/src/plugins/built-in/globalSearch/src/core/index.ts +++ b/src/plugins/built-in/globalSearch/src/core/index.ts @@ -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 = { 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 = { 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 () => { diff --git a/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts b/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts index 6551031e..d2c3cbb9 100644 --- a/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts +++ b/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts @@ -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 */ ` - - - - -

Quick search...

- ⌘K - `; + + const updateSearchButtonDisplay = () => { + searchButton.innerHTML = /* html */ ` + + + + +

Quick search...

+ ${hotkeyDisplay} + `; + }; + 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(() => {}); } diff --git a/src/plugins/built-in/globalSearch/src/utils/hotkeyUtils.ts b/src/plugins/built-in/globalSearch/src/utils/hotkeyUtils.ts new file mode 100644 index 00000000..653b8add --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/utils/hotkeyUtils.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/plugins/core/manager.ts b/src/plugins/core/manager.ts index f973a08c..ff6621db 100644 --- a/src/plugins/core/manager.ts +++ b/src/plugins/core/manager.ts @@ -6,6 +6,7 @@ import type { SelectSetting, StringSetting, ButtonSetting, + HotkeySetting, } from "./types"; import { createPluginAPI } from "./createAPI"; import browser from "webextension-polyfill"; @@ -193,7 +194,8 @@ export class PluginManager { id: string; options: Array<{ value: string; label: string }>; }) - | (Omit & { type: "button"; id: string; trigger?: () => void | Promise }); + | (Omit & { type: "button"; id: string; trigger?: () => void | Promise }) + | (Omit & { type: "hotkey"; id: string }); }; }> { return Array.from(this.plugins.entries()).map(([id, plugin]) => { diff --git a/src/plugins/core/settingsHelpers.ts b/src/plugins/core/settingsHelpers.ts index ff742295..e37bb2e6 100644 --- a/src/plugins/core/settingsHelpers.ts +++ b/src/plugins/core/settingsHelpers.ts @@ -4,6 +4,7 @@ import type { NumberSetting, SelectSetting, StringSetting, + HotkeySetting, } from "./types"; export function numberSetting( @@ -51,6 +52,15 @@ export function buttonSetting( }; } +export function hotkeySetting( + options: Omit, +): HotkeySetting { + return { + type: "hotkey", + ...options, + }; +} + export function defineSettings>(settings: T): T { return settings; } diff --git a/src/plugins/core/types.ts b/src/plugins/core/types.ts index eb8cfce8..1e06b0ea 100644 --- a/src/plugins/core/types.ts +++ b/src/plugins/core/types.ts @@ -41,12 +41,20 @@ export interface ButtonSetting { trigger?: () => void | Promise; } +export interface HotkeySetting { + type: "hotkey"; + default: string; + title: string; + description?: string; +} + export type PluginSetting = | BooleanSetting | StringSetting | NumberSetting | SelectSetting - | ButtonSetting; + | ButtonSetting + | HotkeySetting; export type PluginSettings = { [key: string]: PluginSetting; @@ -61,7 +69,9 @@ export type SettingValue = T extends BooleanSetting ? number : T extends SelectSetting ? O - : never; + : T extends HotkeySetting + ? string + : never; export type SettingsAPI = { [K in keyof T]: SettingValue;