mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
feat: improved hotkey support and controls
This commit is contained in:
@@ -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>
|
||||
@@ -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<ButtonSetting, 'type'> & {
|
||||
type: 'button',
|
||||
id: string
|
||||
}) |
|
||||
(Omit<HotkeySetting, 'type'> & {
|
||||
type: 'hotkey',
|
||||
id: string
|
||||
});
|
||||
|
||||
interface Plugin {
|
||||
@@ -195,10 +200,10 @@
|
||||
{#if (plugin as any).disableToggle}
|
||||
<div class="flex justify-between items-center px-4 py-3">
|
||||
<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}
|
||||
{#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
|
||||
</span>
|
||||
{/if}
|
||||
@@ -258,6 +263,11 @@
|
||||
onClick={() => setting.trigger?.()}
|
||||
text={setting.title}
|
||||
/>
|
||||
{:else if setting.type === 'hotkey'}
|
||||
<HotkeyInput
|
||||
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
||||
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -49,6 +128,13 @@
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<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]) => {
|
||||
|
||||
@@ -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, "type">,
|
||||
): HotkeySetting {
|
||||
return {
|
||||
type: "hotkey",
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
export function defineSettings<T extends Record<string, any>>(settings: T): T {
|
||||
return settings;
|
||||
}
|
||||
|
||||
@@ -41,12 +41,20 @@ export interface ButtonSetting {
|
||||
trigger?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface HotkeySetting {
|
||||
type: "hotkey";
|
||||
default: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type PluginSetting =
|
||||
| BooleanSetting
|
||||
| StringSetting
|
||||
| NumberSetting
|
||||
| SelectSetting<string>
|
||||
| ButtonSetting;
|
||||
| ButtonSetting
|
||||
| HotkeySetting;
|
||||
|
||||
export type PluginSettings = {
|
||||
[key: string]: PluginSetting;
|
||||
@@ -61,7 +69,9 @@ export type SettingValue<T extends PluginSetting> = T extends BooleanSetting
|
||||
? number
|
||||
: T extends SelectSetting<infer O>
|
||||
? O
|
||||
: never;
|
||||
: T extends HotkeySetting
|
||||
? string
|
||||
: never;
|
||||
|
||||
export type SettingsAPI<T extends PluginSettings> = {
|
||||
[K in keyof T]: SettingValue<T[K]>;
|
||||
|
||||
Reference in New Issue
Block a user