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
+229
View File
@@ -0,0 +1,229 @@
<script lang="ts">
import { isValidHotkey, parseHotkey } from '@/plugins/built-in/globalSearch/src/utils/hotkeyUtils';
let { value, onChange } = $props<{
value: string,
onChange: (newValue: string) => void
}>();
let isRecording = $state(false);
let recordedKeys = $state<Set<string>>(new Set());
let inputElement = $state<HTMLInputElement>();
const formatKeyForHotkey = (key: string): string => {
// Map special keys to their hotkey format
const keyMap: Record<string, string> = {
'Control': 'ctrl',
'Meta': 'cmd',
'Alt': 'alt',
'Shift': 'shift',
' ': 'space',
'ArrowUp': 'up',
'ArrowDown': 'down',
'ArrowLeft': 'left',
'ArrowRight': 'right',
'Escape': 'esc',
'Enter': 'enter',
'Tab': 'tab',
'Backspace': 'backspace',
'Delete': 'delete',
};
return keyMap[key] || key.toLowerCase();
};
const formatKeyForDisplay = (key: string): string => {
// Map keys to their display format
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const keyMap: Record<string, string> = {
'ctrl': isMac ? '⌃' : 'Ctrl',
'cmd': '⌘',
'meta': '⌘',
'alt': isMac ? '⌥' : 'Alt',
'shift': isMac ? '⇧' : 'Shift',
'space': 'Space',
'up': '↑',
'down': '↓',
'left': '←',
'right': '→',
'esc': 'Esc',
'enter': 'Enter',
'tab': 'Tab',
'backspace': 'Backspace',
'delete': 'Delete',
};
return keyMap[key.toLowerCase()] || key.toUpperCase();
};
const getHotkeyParts = (hotkeyString: string): string[] => {
if (!hotkeyString || !isValidHotkey(hotkeyString)) {
return [];
}
const parsed = parseHotkey(hotkeyString);
const parts: string[] = [];
// Add modifiers in a consistent order
if (parsed.ctrl) parts.push('ctrl');
if (parsed.meta) parts.push('cmd');
if (parsed.alt) parts.push('alt');
if (parsed.shift) parts.push('shift');
// Add the main key
if (parsed.key) parts.push(parsed.key);
return parts;
};
const startRecording = () => {
isRecording = true;
recordedKeys.clear();
inputElement?.focus();
};
const stopRecording = () => {
if (recordedKeys.size > 0) {
if (recordedKeys.has('esc')) {
onChange('');
isRecording = false;
recordedKeys.clear();
inputElement?.blur();
return;
}
// Build the hotkey string
const modifiers: string[] = [];
let mainKey = '';
for (const key of recordedKeys) {
if (['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
modifiers.push(key);
} else {
mainKey = key;
}
}
if (mainKey) {
const hotkeyString = [...modifiers, mainKey].join('+');
if (isValidHotkey(hotkeyString)) {
onChange(hotkeyString);
}
}
}
isRecording = false;
recordedKeys.clear();
inputElement?.blur();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isRecording) return;
e.preventDefault();
e.stopPropagation();
const key = formatKeyForHotkey(e.key);
// Add modifiers
if (e.ctrlKey) recordedKeys.add('ctrl');
if (e.metaKey) recordedKeys.add('cmd');
if (e.altKey) recordedKeys.add('alt');
if (e.shiftKey) recordedKeys.add('shift');
// Add the main key (ignore modifier keys themselves)
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
recordedKeys.add(key);
}
// Auto-stop recording if we have a main key
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
setTimeout(stopRecording, 100);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!isRecording) return;
e.preventDefault();
e.stopPropagation();
};
const handleBlur = () => {
if (isRecording) {
stopRecording();
}
};
$effect(() => {
if (isRecording && inputElement) {
inputElement.focus();
}
});
// Get the parts to display
const hotkeyParts = $derived(isRecording
? Array.from(recordedKeys).map(formatKeyForDisplay)
: getHotkeyParts(value).map(formatKeyForDisplay));
</script>
<div class="flex gap-2 items-center">
<div class="relative">
{#if isRecording}
<!-- Recording state -->
<div
class="flex items-center justify-center px-3 py-2 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white border cursor-pointer text-nowrap"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
{#if hotkeyParts.length === 0}
<span class="text-gray-500 dark:text-gray-400">Press keys...</span>
{/if}
</div>
{:else if hotkeyParts.length > 0}
<!-- Display current hotkey -->
<div
class="flex gap-1 items-center text-sm rounded-md border-none cursor-pointer dark:text-white"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
{#each hotkeyParts as part}
<div class="size-8 text-sm flex items-center justify-center rounded-md border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30">
{part}
</div>
{/each}
</div>
{:else}
<!-- Empty state -->
<div
class="flex items-center justify-center px-3 py-2 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD] dark:text-white border-none cursor-pointer text-nowrap"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
<span class="text-gray-500 dark:text-gray-400">Click to set</span>
</div>
{/if}
<!-- Hidden input for focus management -->
<input
bind:this={inputElement}
type="text"
readonly
class="absolute inset-0 opacity-0 pointer-events-none"
onkeydown={handleKeyDown}
onkeyup={handleKeyUp}
onblur={handleBlur}
/>
</div>
</div>
<style>
input:focus {
outline: none;
}
</style>
+13 -3
View File
@@ -3,6 +3,7 @@
import Button from "../../components/Button.svelte" import Button from "../../components/Button.svelte"
import Slider from "../../components/Slider.svelte" import Slider from "../../components/Slider.svelte"
import Select from "@/interface/components/Select.svelte" import Select from "@/interface/components/Select.svelte"
import HotkeyInput from "@/interface/components/HotkeyInput.svelte"
import browser from "webextension-polyfill" import browser from "webextension-polyfill"
@@ -12,7 +13,7 @@
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent" import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"
import { getAllPluginSettings } from "@/plugins" 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 // Union type representing all possible settings
type SettingType = type SettingType =
@@ -27,6 +28,10 @@
(Omit<ButtonSetting, 'type'> & { (Omit<ButtonSetting, 'type'> & {
type: 'button', type: 'button',
id: string id: string
}) |
(Omit<HotkeySetting, 'type'> & {
type: 'hotkey',
id: string
}); });
interface Plugin { interface Plugin {
@@ -195,10 +200,10 @@
{#if (plugin as any).disableToggle} {#if (plugin as any).disableToggle}
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold flex items-center gap-2"> <h2 class="flex gap-2 items-center text-sm font-bold">
Enable {plugin.name} Enable {plugin.name}
{#if plugin.beta} {#if plugin.beta}
<span class="px-2 py-0.5 text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 rounded-full"> <span class="px-2 py-0.5 text-xs font-medium text-orange-800 bg-orange-100 rounded-full dark:bg-orange-900/30 dark:text-orange-300">
Beta Beta
</span> </span>
{/if} {/if}
@@ -258,6 +263,11 @@
onClick={() => setting.trigger?.()} onClick={() => setting.trigger?.()}
text={setting.title} text={setting.title}
/> />
{:else if setting.type === 'hotkey'}
<HotkeyInput
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{/if} {/if}
</div> </div>
</div> </div>
@@ -13,15 +13,22 @@
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { renderComponentMap } from '../indexing/renderComponents'; import { renderComponentMap } from '../indexing/renderComponents';
import HighlightedText from '../utils/HighlightedText.svelte'; import HighlightedText from '../utils/HighlightedText.svelte';
import { matchesHotkey } from '../utils/hotkeyUtils';
import browser from 'webextension-polyfill';
const { const {
transparencyEffects, transparencyEffects,
showRecentFirst showRecentFirst,
searchHotkey: initialSearchHotkey
} = $props<{ } = $props<{
transparencyEffects: boolean, transparencyEffects: boolean,
showRecentFirst: boolean showRecentFirst: boolean,
searchHotkey: string
}>(); }>();
// Make searchHotkey reactive to setting changes
let currentSearchHotkey = $state(initialSearchHotkey);
let commandsFuse = $state<Fuse<StaticCommandItem>>(); let commandsFuse = $state<Fuse<StaticCommandItem>>();
let dynamicContentFuse = $state<Fuse<IndexItem>>(); let dynamicContentFuse = $state<Fuse<IndexItem>>();
@@ -32,6 +39,78 @@
let completedJobs = $state(0); let completedJobs = $state(0);
let totalJobs = $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(() => { onMount(() => {
const progressHandler = (event: CustomEvent) => { const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing } = event.detail; const { completed, total, indexing } = event.detail;
@@ -48,7 +127,14 @@
performSearch(); performSearch();
}; };
window.addEventListener('dynamic-items-updated', itemsUpdatedHandler); window.addEventListener('dynamic-items-updated', itemsUpdatedHandler);
setupSearchIndexes();
// @ts-ignore - Intentionally adding to window
window.setCommandPalleteOpen = (open: boolean) => {
commandPalleteOpen = open;
};
return () => { return () => {
window.removeEventListener('indexing-progress', progressHandler as EventListener); window.removeEventListener('indexing-progress', progressHandler as EventListener);
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler); window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
@@ -69,43 +155,6 @@
console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`); 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 () => { const performSearch = async () => {
isLoading = true; isLoading = true;
@@ -201,6 +250,7 @@
}; };
const handleKeyNav = (e: KeyboardEvent) => { const handleKeyNav = (e: KeyboardEvent) => {
// Handle regular navigation
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
selectNext(); selectNext();
@@ -281,11 +331,6 @@
<span class="ml-4 text-lg truncate"> <span class="ml-4 text-lg truncate">
<HighlightedText text={staticItem.text} term={searchTerm} matches={result.matches} /> <HighlightedText text={staticItem.text} term={searchTerm} matches={result.matches} />
</span> </span>
{#if staticItem.keybindLabel}
<div class="flex-none ml-auto">
{@render Shortcut({ text: '', keybind: staticItem.keybindLabel })}
</div>
{/if}
</button> </button>
{:else if result.type === 'dynamic'} {:else if result.type === 'dynamic'}
{@const dynamicItem = item as IndexItem} {@const dynamicItem = item as IndexItem}
@@ -344,38 +389,26 @@
{#if combinedResults.length > 0 || calculatorResult} {#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 justify-between items-center h-5 text-sm text-zinc-500 dark:text-zinc-400">
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
{#if !calculatorResult} {@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
{#if selectedIndex >= 0 && selectedIndex < combinedResults.length} {#if calculatorResult && selectedIndex === 0}
{@const item = combinedResults[selectedIndex].item} {@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
{#if 'keybind' in item && item.keybind} {:else}
{@render Shortcut({ text: 'Shortcut', keybind: [ ...(item?.keybindLabel ?? []) ] })} {@render Shortcut({ text: 'Select', keybind: ['↵']})}
{/if}
{/if}
{/if} {/if}
</div> </div>
<div> {#if isIndexing}
<div class="flex gap-4 items-center"> <div class="inset-x-0 top-0">
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})} <div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
{#if calculatorResult && selectedIndex === 0} Indexing
{@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>
</div> </div>
{/if} <div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
</div> <div
class="h-full bg-blue-500 transition-all duration-300 ease-out"
style="width: {(completedJobs / totalJobs) * 100}%"
></div>
</div>
</div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -22,8 +22,6 @@ const staticCommands: StaticCommandItem[] = [
icon: "\ueb4c", icon: "\ueb4c",
category: "navigation", category: "navigation",
text: "Home", text: "Home",
keybind: ["alt+h"],
keybindLabel: ["Alt", "H"],
action: () => { action: () => {
window.location.hash = "?page=/home"; window.location.hash = "?page=/home";
loadHomePage(); loadHomePage();
@@ -35,8 +33,6 @@ const staticCommands: StaticCommandItem[] = [
icon: "\uebfd", icon: "\uebfd",
category: "navigation", category: "navigation",
text: "Direct Messages", text: "Direct Messages",
keybind: ["alt+m"],
keybindLabel: ["Alt", "M"],
action: () => { action: () => {
window.location.hash = "?page=/messages"; window.location.hash = "?page=/messages";
}, },
@@ -47,8 +43,6 @@ const staticCommands: StaticCommandItem[] = [
icon: "\ue9cd", icon: "\ue9cd",
category: "navigation", category: "navigation",
text: "Timetable", text: "Timetable",
keybind: ["alt+t"],
keybindLabel: ["Alt", "T"],
action: () => { action: () => {
window.location.hash = "?page=/timetable"; window.location.hash = "?page=/timetable";
}, },
@@ -59,10 +53,8 @@ const staticCommands: StaticCommandItem[] = [
icon: "\ueac3", icon: "\ueac3",
category: "navigation", category: "navigation",
text: "Assessments", text: "Assessments",
keybind: ["alt+a"],
keybindLabel: ["Alt", "A"],
action: () => { action: () => {
window.location.hash = "?page=/assessments"; window.location.hash = "?page=/assessments/upcoming";
}, },
priority: 4, priority: 4,
}, },
@@ -5,7 +5,7 @@ import {
buttonSetting, buttonSetting,
defineSettings, defineSettings,
Setting, Setting,
stringSetting, hotkeySetting,
} from "@/plugins/core/settingsHelpers"; } from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
@@ -14,11 +14,17 @@ import { initVectorSearch } from "../search/vector/vectorSearch";
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar"; import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
import { IndexedDbManager } from "embeddia"; 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({ const settings = defineSettings({
searchHotkey: stringSetting({ searchHotkey: hotkeySetting({
default: "ctrl+k", default: getDefaultHotkey(),
title: "Search Hotkey", title: "Search Hotkey",
description: "Keyboard shortcut to open the search (cmd on Mac)", description: "Keyboard shortcut to open the search",
}), }),
showRecentFirst: booleanSetting({ showRecentFirst: booleanSetting({
default: true, default: true,
@@ -99,10 +105,15 @@ const globalSearchPlugin: Plugin<typeof settings> = {
run: async (api) => { run: async (api) => {
const appRef = { current: null }; const appRef = { current: null };
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", { try {
primaryKey: "id", await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
autoIncrement: false, primaryKey: "id",
}); autoIncrement: false,
});
} catch (error) {
console.error("Failed to create IndexedDB:", error);
// Continue execution - the search might still work without persistence
}
initVectorSearch(); initVectorSearch();
@@ -117,8 +128,8 @@ const globalSearchPlugin: Plugin<typeof settings> = {
if (title) { if (title) {
mountSearchBar(title, api, appRef); mountSearchBar(title, api, appRef);
} else { } else {
await waitForElm("#title", true, 100, 60); const titleElement = await waitForElm("#title", true, 100, 60);
mountSearchBar(document.querySelector("#title") as Element, api, appRef); mountSearchBar(titleElement, api, appRef);
} }
return () => { return () => {
@@ -2,6 +2,8 @@ import renderSvelte from "@/interface/main";
import SearchBar from "../components/SearchBar.svelte"; import SearchBar from "../components/SearchBar.svelte";
import { unmount } from "svelte"; import { unmount } from "svelte";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager"; import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
import browser from "webextension-polyfill";
export function mountSearchBar( export function mountSearchBar(
titleElement: Element, titleElement: Element,
@@ -12,19 +14,41 @@ export function mountSearchBar(
return; 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"); const searchButton = document.createElement("div");
searchButton.className = "search-trigger"; 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"> const updateSearchButtonDisplay = () => {
<circle cx="11" cy="11" r="8"></circle> searchButton.innerHTML = /* html */ `
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <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">
</svg> <circle cx="11" cy="11" r="8"></circle>
<p>Quick search...</p> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">⌘K</span> </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); 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"); const searchRoot = document.createElement("div");
document.body.appendChild(searchRoot); document.body.appendChild(searchRoot);
const searchRootShadow = searchRoot.attachShadow({ mode: "open" }); const searchRootShadow = searchRoot.attachShadow({ mode: "open" });
@@ -38,6 +62,7 @@ export function mountSearchBar(
appRef.current = renderSvelte(SearchBar, searchRootShadow, { appRef.current = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects ? true : false, transparencyEffects: api.settings.transparencyEffects ? true : false,
showRecentFirst: api.settings.showRecentFirst, showRecentFirst: api.settings.showRecentFirst,
searchHotkey: currentHotkey,
}); });
} catch (error) { } catch (error) {
console.error("Error rendering Svelte component:", error); console.error("Error rendering Svelte component:", error);
@@ -45,12 +70,30 @@ export function mountSearchBar(
} }
export function cleanupSearchBar(appRef: { current: any }) { export function cleanupSearchBar(appRef: { current: any }) {
const searchButton = document.querySelector(".search-trigger"); if (appRef.current) {
const searchRoot = document.querySelector(".global-search-root"); try {
if (searchButton) searchButton.remove(); unmount(appRef.current);
if (searchRoot) searchRoot.remove(); 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(); 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;
}
}
+3 -1
View File
@@ -6,6 +6,7 @@ import type {
SelectSetting, SelectSetting,
StringSetting, StringSetting,
ButtonSetting, ButtonSetting,
HotkeySetting,
} from "./types"; } from "./types";
import { createPluginAPI } from "./createAPI"; import { createPluginAPI } from "./createAPI";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
@@ -193,7 +194,8 @@ export class PluginManager {
id: string; id: string;
options: Array<{ value: string; label: string }>; options: Array<{ value: string; label: string }>;
}) })
| (Omit<ButtonSetting, "type"> & { type: "button"; id: string; trigger?: () => void | Promise<void> }); | (Omit<ButtonSetting, "type"> & { type: "button"; id: string; trigger?: () => void | Promise<void> })
| (Omit<HotkeySetting, "type"> & { type: "hotkey"; id: string });
}; };
}> { }> {
return Array.from(this.plugins.entries()).map(([id, plugin]) => { return Array.from(this.plugins.entries()).map(([id, plugin]) => {
+10
View File
@@ -4,6 +4,7 @@ import type {
NumberSetting, NumberSetting,
SelectSetting, SelectSetting,
StringSetting, StringSetting,
HotkeySetting,
} from "./types"; } from "./types";
export function numberSetting( export function numberSetting(
@@ -51,6 +52,15 @@ export function buttonSetting(
}; };
} }
export function hotkeySetting(
options: Omit<HotkeySetting, "type">,
): HotkeySetting {
return {
type: "hotkey",
...options,
};
}
export function defineSettings<T extends Record<string, any>>(settings: T): T { export function defineSettings<T extends Record<string, any>>(settings: T): T {
return settings; return settings;
} }
+12 -2
View File
@@ -41,12 +41,20 @@ export interface ButtonSetting {
trigger?: () => void | Promise<void>; trigger?: () => void | Promise<void>;
} }
export interface HotkeySetting {
type: "hotkey";
default: string;
title: string;
description?: string;
}
export type PluginSetting = export type PluginSetting =
| BooleanSetting | BooleanSetting
| StringSetting | StringSetting
| NumberSetting | NumberSetting
| SelectSetting<string> | SelectSetting<string>
| ButtonSetting; | ButtonSetting
| HotkeySetting;
export type PluginSettings = { export type PluginSettings = {
[key: string]: PluginSetting; [key: string]: PluginSetting;
@@ -61,7 +69,9 @@ export type SettingValue<T extends PluginSetting> = T extends BooleanSetting
? number ? number
: T extends SelectSetting<infer O> : T extends SelectSetting<infer O>
? O ? O
: never; : T extends HotkeySetting
? string
: never;
export type SettingsAPI<T extends PluginSettings> = { export type SettingsAPI<T extends PluginSettings> = {
[K in keyof T]: SettingValue<T[K]>; [K in keyof T]: SettingValue<T[K]>;