diff --git a/app.vue b/app.vue index 6b9f531..14363d4 100644 --- a/app.vue +++ b/app.vue @@ -64,6 +64,8 @@ onMounted(async () => { --accent: #feb453; --border: #ffffff0d; + --red: #F84E4E; + --text: #e5dfd5; --text-secondary: #ada9a1; --text-muted: #78756f; diff --git a/components/ActionsMenu.vue b/components/ActionsMenu.vue new file mode 100644 index 0000000..37fbae8 --- /dev/null +++ b/components/ActionsMenu.vue @@ -0,0 +1,798 @@ + + + + + diff --git a/components/BottomBar.vue b/components/BottomBar.vue index 2ec32ed..4bb59b9 100644 --- a/components/BottomBar.vue +++ b/components/BottomBar.vue @@ -5,17 +5,21 @@

Qopy

-
+

{{ primaryAction.text }}

- +
+ + + +
-
+

{{ secondaryAction.text }}

-
- +
+ - +
@@ -23,13 +27,17 @@ @@ -78,7 +100,7 @@ onMounted(() => { color: var(--text); } - .actions div { + .keys { display: flex; align-items: center; gap: 2px; @@ -111,6 +133,12 @@ onMounted(() => { background-color: var(--border); } + .paste:active, + .actions:active { + background-color: var(--border-active, #444); + transform: scale(0.98); + } + &:hover .paste:hover ~ .divider, &:hover .divider:has(+ .actions:hover) { opacity: 0; diff --git a/components/Icons/Bin.vue b/components/Icons/Bin.vue new file mode 100644 index 0000000..d69f6a4 --- /dev/null +++ b/components/Icons/Bin.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/components/Icons/Board.vue b/components/Icons/Board.vue new file mode 100644 index 0000000..7f45d86 --- /dev/null +++ b/components/Icons/Board.vue @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/components/Icons/Brush.vue b/components/Icons/Brush.vue new file mode 100644 index 0000000..d282b03 --- /dev/null +++ b/components/Icons/Brush.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/components/Icons/Ctrl.vue b/components/Icons/Ctrl.vue deleted file mode 100644 index 60163bd..0000000 --- a/components/Icons/Ctrl.vue +++ /dev/null @@ -1,32 +0,0 @@ - diff --git a/components/Icons/Expand.vue b/components/Icons/Expand.vue new file mode 100644 index 0000000..41e86ae --- /dev/null +++ b/components/Icons/Expand.vue @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/components/Icons/Gear.vue b/components/Icons/Gear.vue new file mode 100644 index 0000000..581378e --- /dev/null +++ b/components/Icons/Gear.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/components/Icons/Globe.vue b/components/Icons/Globe.vue new file mode 100644 index 0000000..f3203ed --- /dev/null +++ b/components/Icons/Globe.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/components/Icons/K.vue b/components/Icons/K.vue deleted file mode 100644 index 8bdc675..0000000 --- a/components/Icons/K.vue +++ /dev/null @@ -1,29 +0,0 @@ - diff --git a/components/Icons/Open.vue b/components/Icons/Open.vue new file mode 100644 index 0000000..f022013 --- /dev/null +++ b/components/Icons/Open.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/components/Icons/Pen.vue b/components/Icons/Pen.vue new file mode 100644 index 0000000..5a5c7f8 --- /dev/null +++ b/components/Icons/Pen.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/components/Icons/Rotate.vue b/components/Icons/Rotate.vue new file mode 100644 index 0000000..84f57d7 --- /dev/null +++ b/components/Icons/Rotate.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/components/Icons/T.vue b/components/Icons/T.vue new file mode 100644 index 0000000..61dcdbe --- /dev/null +++ b/components/Icons/T.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/components/Icons/Zip.vue b/components/Icons/Zip.vue new file mode 100644 index 0000000..e1bf671 --- /dev/null +++ b/components/Icons/Zip.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/components/Icons/Cmd.vue b/components/Keys/Cmd.vue similarity index 100% rename from components/Icons/Cmd.vue rename to components/Keys/Cmd.vue diff --git a/components/Icons/Enter.vue b/components/Keys/Enter.vue similarity index 100% rename from components/Icons/Enter.vue rename to components/Keys/Enter.vue diff --git a/components/Keys/Key.vue b/components/Keys/Key.vue new file mode 100644 index 0000000..181ac17 --- /dev/null +++ b/components/Keys/Key.vue @@ -0,0 +1,30 @@ + + + diff --git a/components/Keys/Shift.vue b/components/Keys/Shift.vue new file mode 100644 index 0000000..5af5a9a --- /dev/null +++ b/components/Keys/Shift.vue @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/composables/useActions.ts b/composables/useActions.ts new file mode 100644 index 0000000..78d2254 --- /dev/null +++ b/composables/useActions.ts @@ -0,0 +1,222 @@ +import { invoke } from "@tauri-apps/api/core"; +import { HistoryItem } from "../types/types"; + +const { $history } = useNuxtApp(); +const { hideApp } = useAppControl(); + +export function useActions() { + const isProcessing = ref(false); + + const handleAction = async (action: string, item?: HistoryItem) => { + if (!item && action !== "settings" && action !== "delete-all") return; + + isProcessing.value = true; + + try { + switch (action) { + case "paste-to-app": + await pasteToCurrentApp(item); + break; + case "copy": + // await copyToClipboard(item); + break; + case "delete": + await deleteEntry(item); + break; + case "delete-all": + // await deleteAllEntries(); + break; + case "settings": + openSettings(); + break; + case "paste-plain": + // await pasteAsPlainText(item); + break; + case "edit-text": + // openTextEditor(item); + break; + case "rotate-image": + // await rotateImage(item); + break; + case "resize-image": + // openImageResizer(item); + break; + case "compress-image": + // await compressImage(item); + break; + case "open-file": + // await openFile(item); + break; + case "compress-file": + // await compressFile(item); + break; + case "open-link": + // await openInBrowser(item); + break; + case "copy-hex": + // await copyColorFormat(item, "hex"); + break; + case "copy-rgba": + // await copyColorFormat(item, "rgba"); + break; + case "copy-hsla": + // await copyColorFormat(item, "hsla"); + break; + default: + console.warn(`Action ${action} not implemented`); + } + } catch (error) { + console.error(`Error executing action ${action}:`, error); + } finally { + isProcessing.value = false; + } + }; + + const pasteToCurrentApp = async (item?: HistoryItem) => { + if (!item) return; + + let content = item.content; + let contentType: string = item.content_type; + if (contentType === "image") { + try { + content = await $history.readImage({ filename: content }); + } catch (error) { + console.error("Error reading image file:", error); + return; + } + } + await hideApp(); + await $history.writeAndPaste({ content, contentType }); + }; + + // const copyToClipboard = async (item?: HistoryItem) => { + // if (!item) return; + + // try { + // switch (item.content_type) { + // case ContentType.Text: + // case ContentType.Link: + // case ContentType.Code: + // await writeText(item.content); + // break; + // case ContentType.Image: + // await invoke("copy_image_to_clipboard", { path: item.file_path }); + // break; + // case ContentType.File: + // await invoke("copy_file_reference", { path: item.file_path }); + // break; + // case ContentType.Color: + // await writeText(item.content); + // break; + // default: + // console.warn(`Copying type ${item.content_type} not implemented`); + // } + // } catch (error) { + // console.error("Failed to copy to clipboard:", error); + // } + // }; + + const deleteEntry = async (item?: HistoryItem) => { + if (!item) return; + try { + await invoke("delete_history_item", { id: item.id }); + } catch (error) { + console.error("Failed to delete entry:", error); + } + }; + + // const deleteAllEntries = async () => { + // try { + // await invoke('delete_all_history'); + // } catch (error) { + // console.error('Failed to delete all entries:', error); + // } + // }; + + const openSettings = () => { + navigateTo("/settings"); + }; + + // const pasteAsPlainText = async (item?: HistoryItem) => { + // if (!item) return; + // try { + // await invoke('paste_as_plain_text', { content: item.content }); + // } catch (error) { + // console.error('Failed to paste as plain text:', error); + // } + // }; + + // const openTextEditor = (item?: HistoryItem) => { + // if (!item) return; + // // Implement logic to open text editor with the content + // // This might use Nuxt router or a modal based on your app architecture + // }; + + // const rotateImage = async (item?: HistoryItem) => { + // if (!item || item.content_type !== ContentType.Image) return; + // try { + // await invoke('rotate_image', { path: item.file_path }); + // } catch (error) { + // console.error('Failed to rotate image:', error); + // } + // }; + + // const openImageResizer = (item?: HistoryItem) => { + // if (!item || item.content_type !== ContentType.Image) return; + // // Implement logic to open image resizer UI for this image + // }; + + // const compressImage = async (item?: HistoryItem) => { + // if (!item || item.content_type !== ContentType.Image) return; + // try { + // await invoke('compress_image', { path: item.file_path }); + // } catch (error) { + // console.error('Failed to compress image:', error); + // } + // }; + + // const openFile = async (item?: HistoryItem) => { + // if (!item || item.content_type !== ContentType.File) return; + // try { + // await invoke('open_file', { path: item.file_path }); + // } catch (error) { + // console.error('Failed to open file:', error); + // } + // }; + + // const compressFile = async (item?: HistoryItem) => { + // if (!item || item.content_type !== ContentType.File) return; + // try { + // await invoke('compress_file', { path: item.file_path }); + // } catch (error) { + // console.error('Failed to compress file:', error); + // } + // }; + + // const openInBrowser = async (item?: HistoryItem) => { + // if (!item || item.content_type !== ContentType.Link) return; + // try { + // await invoke('open_url', { url: item.content }); + // } catch (error) { + // console.error('Failed to open URL in browser:', error); + // } + // }; + + // const copyColorFormat = async (item?: HistoryItem, format: 'hex' | 'rgba' | 'hsla' = 'hex') => { + // if (!item || item.content_type !== ContentType.Color) return; + // try { + // const formattedColor = await invoke('get_color_format', { + // color: item.content, + // format + // }); + // await writeText(formattedColor as string); + // } catch (error) { + // console.error(`Failed to copy color as ${format}:`, error); + // } + // }; + + return { + handleAction, + isProcessing, + }; +} diff --git a/composables/useAppControl.ts b/composables/useAppControl.ts new file mode 100644 index 0000000..028f881 --- /dev/null +++ b/composables/useAppControl.ts @@ -0,0 +1,12 @@ +import { app, window } from "@tauri-apps/api"; + +export function useAppControl() { + const hideApp = async (): Promise => { + await app.hide(); + await window.getCurrentWindow().hide(); + }; + + return { + hideApp + }; +} \ No newline at end of file diff --git a/lib/selectedResult.ts b/lib/selectedResult.ts deleted file mode 100644 index e53411b..0000000 --- a/lib/selectedResult.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { HistoryItem } from '~/types/types' - -interface GroupedHistory { - label: string - items: HistoryItem[] -} - -export const selectedGroupIndex = ref(0) -export const selectedItemIndex = ref(0) -export const selectedElement = ref(null) - -export const useSelectedResult = (groupedHistory: Ref) => { - const selectedItem = computed(() => { - const group = groupedHistory.value[selectedGroupIndex.value] - return group?.items[selectedItemIndex.value] ?? null - }) - - const isSelected = (groupIndex: number, itemIndex: number): boolean => { - return selectedGroupIndex.value === groupIndex && selectedItemIndex.value === itemIndex - } - - const selectNext = (): void => { - const currentGroup = groupedHistory.value[selectedGroupIndex.value] - if (selectedItemIndex.value < currentGroup.items.length - 1) { - selectedItemIndex.value++ - } else if (selectedGroupIndex.value < groupedHistory.value.length - 1) { - selectedGroupIndex.value++ - selectedItemIndex.value = 0 - } - } - - const selectPrevious = (): void => { - if (selectedItemIndex.value > 0) { - selectedItemIndex.value-- - } else if (selectedGroupIndex.value > 0) { - selectedGroupIndex.value-- - selectedItemIndex.value = groupedHistory.value[selectedGroupIndex.value].items.length - 1 - } - } - - const selectItem = (groupIndex: number, itemIndex: number): void => { - selectedGroupIndex.value = groupIndex - selectedItemIndex.value = itemIndex - } - - return { - selectedItem, - isSelected, - selectNext, - selectPrevious, - selectItem, - selectedElement - } -} \ No newline at end of file diff --git a/nuxt.config.ts b/nuxt.config.ts index b10de45..77635be 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -3,6 +3,7 @@ export default defineNuxtConfig({ devtools: { enabled: false }, compatibilityDate: "2024-07-04", ssr: false, + app: { head: { charset: "utf-8", @@ -10,6 +11,7 @@ export default defineNuxtConfig({ "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0", }, }, + vite: { css: { preprocessorOptions: { @@ -19,4 +21,4 @@ export default defineNuxtConfig({ }, }, }, -}); +}); \ No newline at end of file diff --git a/pages/index.vue b/pages/index.vue index 0accd10..774b0a0 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -10,7 +10,7 @@ + @setRef="(el: HTMLElement | null) => (selectedElement = el)" />
@@ -49,9 +49,12 @@ onClick: pasteSelectedItem, }" :secondary-action="{ text: 'Actions', - icon: IconsK, + icon: IconsKey, + input: 'K', showModifier: true, + onClick: toggleActionsMenu, }" /> + @@ -59,7 +62,6 @@ import { ref, computed, onMounted, watch, nextTick, shallowRef } from "vue"; import { OverlayScrollbarsComponent } from "overlayscrollbars-vue"; import "overlayscrollbars/overlayscrollbars.css"; -import { app, window } from "@tauri-apps/api"; import { platform } from "@tauri-apps/plugin-os"; import { listen } from "@tauri-apps/api/event"; import { useNuxtApp } from "#app"; @@ -73,22 +75,18 @@ import type { InfoColor, InfoCode, } from "~/types/types"; -import { Key, keyboard } from "wrdu-keyboard"; -import { - selectedGroupIndex, - selectedItemIndex, - selectedElement, - useSelectedResult, -} from "~/lib/selectedResult"; -import IconsEnter from "~/components/Icons/Enter.vue"; -import IconsK from "~/components/Icons/K.vue"; +import IconsEnter from "~/components/Keys/Enter.vue"; +import IconsKey from "~/components/Keys/Key.vue"; +import ActionsMenu from "~/components/ActionsMenu.vue"; +import { useAppControl } from "~/composables/useAppControl"; interface GroupedHistory { label: string; items: HistoryItem[]; } -const { $history } = useNuxtApp(); +const { $history, $keyboard, $selectedResult } = useNuxtApp(); +const { selectedGroupIndex, selectedItemIndex, selectedElement, useSelectedResult } = $selectedResult; const CHUNK_SIZE = 50; const SCROLL_THRESHOLD = 100; @@ -113,9 +111,36 @@ const imageLoadError = ref(false); const imageLoading = ref(false); const pageTitle = ref(""); const pageOgImage = ref(""); +const isActionsMenuVisible = ref(false); const topBar = ref<{ searchInput: HTMLInputElement | null } | null>(null); +const toggleActionsMenu = () => { + isActionsMenuVisible.value = !isActionsMenuVisible.value; + + if (isActionsMenuVisible.value) { + $keyboard.disableContext('main'); + $keyboard.enableContext('actionsMenu'); + } else { + $keyboard.disableContext('actionsMenu'); + $keyboard.enableContext('main'); + } + + nextTick(() => { + if (isActionsMenuVisible.value) { + document.getElementById('actions-menu')?.focus(); + } else { + focusSearchInput(); + } + }); +}; + +const closeActionsMenu = () => { + isActionsMenuVisible.value = false; + $keyboard.disableContext('actionsMenu'); + $keyboard.enableContext('main'); +}; + const isSameDay = (date1: Date, date2: Date): boolean => { return ( date1.getFullYear() === date2.getFullYear() && @@ -417,13 +442,13 @@ const getYoutubeThumbnail = (url: string): string => { }; const updateHistory = async (resetScroll: boolean = false): Promise => { - const results = await $history.loadHistoryChunk(0, CHUNK_SIZE); + offset = 0; + history.value = []; + + const results = await $history.loadHistoryChunk(offset, CHUNK_SIZE); if (results.length > 0) { - const existingIds = new Set(history.value.map((item) => item.id)); - const uniqueNewItems = results.filter((item) => !existingIds.has(item.id)); - - const processedNewItems = await Promise.all( - uniqueNewItems.map(async (item) => { + const processedItems = await Promise.all( + results.map(async (item) => { const historyItem = new HistoryItem( item.source, item.content_type, @@ -471,7 +496,8 @@ const updateHistory = async (resetScroll: boolean = false): Promise => { }) ); - history.value = [...processedNewItems, ...history.value]; + history.value = processedItems; + offset = results.length; if ( resetScroll && @@ -527,76 +553,40 @@ const setupEventListeners = async (): Promise => { } focusSearchInput(); - keyboard.clear(); - keyboard.prevent.down([Key.DownArrow], () => { - selectNext(); - }); - - keyboard.prevent.down([Key.UpArrow], () => { - selectPrevious(); - }); - - keyboard.prevent.down([Key.Enter], () => { - pasteSelectedItem(); - }); - - keyboard.prevent.down([Key.Escape], () => { - hideApp(); - }); - - switch (os.value) { - case "macos": - keyboard.prevent.down([Key.LeftMeta, Key.K], () => { }); - keyboard.prevent.down([Key.RightMeta, Key.K], () => { }); - break; - - case "linux": - case "windows": - keyboard.prevent.down([Key.LeftControl, Key.K], () => { }); - keyboard.prevent.down([Key.RightControl, Key.K], () => { }); - break; + $keyboard.disableContext('actionsMenu'); + $keyboard.disableContext('settings'); + $keyboard.enableContext('main'); + if (isActionsMenuVisible.value) { + $keyboard.enableContext('actionsMenu'); } }); await listen("tauri://blur", () => { searchInput.value?.blur(); - keyboard.clear(); + $keyboard.disableContext('main'); + $keyboard.disableContext('actionsMenu'); }); - keyboard.prevent.down([Key.DownArrow], () => { - selectNext(); + $keyboard.setupAppShortcuts({ + onNavigateDown: selectNext, + onNavigateUp: selectPrevious, + onSelect: pasteSelectedItem, + onEscape: () => { + if (isActionsMenuVisible.value) { + closeActionsMenu(); + } else { + hideApp(); + } + }, + onToggleActions: toggleActionsMenu, + contextName: 'main', + priority: $keyboard.PRIORITY.HIGH }); - - keyboard.prevent.down([Key.UpArrow], () => { - selectPrevious(); - }); - - keyboard.prevent.down([Key.Enter], () => { - pasteSelectedItem(); - }); - - keyboard.prevent.down([Key.Escape], () => { - hideApp(); - }); - - switch (os.value) { - case "macos": - keyboard.prevent.down([Key.LeftMeta, Key.K], () => { }); - keyboard.prevent.down([Key.RightMeta, Key.K], () => { }); - break; - - case "linux": - case "windows": - keyboard.prevent.down([Key.LeftControl, Key.K], () => { }); - keyboard.prevent.down([Key.RightControl, Key.K], () => { }); - break; - } + $keyboard.disableContext('settings'); + $keyboard.enableContext('main'); }; -const hideApp = async (): Promise => { - await app.hide(); - await window.getCurrentWindow().hide(); -}; +const { hideApp } = useAppControl(); const focusSearchInput = (): void => { nextTick(() => { @@ -628,6 +618,24 @@ onMounted(async () => { ?.viewport?.addEventListener("scroll", handleScroll); await setupEventListeners(); + + $keyboard.setupAppShortcuts({ + onNavigateDown: selectNext, + onNavigateUp: selectPrevious, + onSelect: pasteSelectedItem, + onEscape: () => { + if (isActionsMenuVisible.value) { + closeActionsMenu(); + } else { + hideApp(); + } + }, + onToggleActions: toggleActionsMenu, + contextName: 'main', + priority: $keyboard.PRIORITY.HIGH + }); + $keyboard.disableContext('settings'); + $keyboard.enableContext('main'); } catch (error) { console.error("Error during onMounted:", error); } diff --git a/pages/settings.vue b/pages/settings.vue index f67a52b..4bbf980 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -45,6 +45,7 @@
>(new Set()); const isKeybindInputFocused = ref(false); const keybind = ref([]); const keybindInput = ref(null); const lastBlurTime = ref(0); +const blurredByEscape = ref(false); const os = ref(""); const router = useRouter(); const showEmptyKeybindError = ref(false); const autostart = ref(false); -const { $settings } = useNuxtApp(); +const { $settings, $keyboard } = useNuxtApp(); const modifierKeySet = new Set([ KeyValues.AltLeft, @@ -127,6 +129,7 @@ const onBlur = () => { const onFocus = () => { isKeybindInputFocused.value = true; + blurredByEscape.value = false; activeModifiers.clear(); keybind.value = []; showEmptyKeybindError.value = false; @@ -137,7 +140,10 @@ const onKeyDown = (event: KeyboardEvent) => { if (key === KeyValues.Escape) { if (keybindInput.value) { + blurredByEscape.value = true; keybindInput.value.blur(); + event.preventDefault(); + event.stopPropagation(); } return; } @@ -174,56 +180,64 @@ const toggleAutostart = async () => { os.value = platform(); onMounted(async () => { - keyboard.prevent.down([Key.All], (event: KeyboardEvent) => { - if (isKeybindInputFocused.value) { - onKeyDown(event); + $keyboard.setupKeybindCapture({ + onCapture: (key: string) => { + if (isKeybindInputFocused.value) { + const keyValue = key as KeyValues; + + if (isModifier(keyValue)) { + activeModifiers.add(keyValue); + } else if (!keybind.value.includes(keyValue)) { + keybind.value = keybind.value.filter((k) => isModifier(k)); + keybind.value.push(keyValue); + } + + updateKeybind(); + showEmptyKeybindError.value = false; + } + }, + onComplete: () => { + if (isKeybindInputFocused.value) { + keybindInput.value?.blur(); + } else { + router.push("/"); + } } }); - keyboard.prevent.down([Key.Escape], () => { - if (isKeybindInputFocused.value) { - keybindInput.value?.blur(); - } else { - router.push("/"); - } - }); - - switch (os.value) { - case "macos": - keyboard.prevent.down([Key.LeftMeta, Key.Enter], () => { - if (!isKeybindInputFocused.value) { - saveKeybind(); - } - }); - - keyboard.prevent.down([Key.RightMeta, Key.Enter], () => { - if (!isKeybindInputFocused.value) { - saveKeybind(); - } - }); - break; - - case "linux": - case "windows": - keyboard.prevent.down([Key.LeftControl, Key.Enter], () => { - if (!isKeybindInputFocused.value) { - saveKeybind(); - } - }); - - keyboard.prevent.down([Key.RightControl, Key.Enter], () => { - if (!isKeybindInputFocused.value) { - saveKeybind(); - } - }); - break; + if (os.value === "macos") { + $keyboard.on("settings", [$keyboard.Key.LeftMeta, $keyboard.Key.Enter], () => { + saveKeybind(); + }, { priority: $keyboard.PRIORITY.HIGH }); + + $keyboard.on("settings", [$keyboard.Key.RightMeta, $keyboard.Key.Enter], () => { + saveKeybind(); + }, { priority: $keyboard.PRIORITY.HIGH }); + } else { + $keyboard.on("settings", [$keyboard.Key.LeftControl, $keyboard.Key.Enter], () => { + saveKeybind(); + }, { priority: $keyboard.PRIORITY.HIGH }); + + $keyboard.on("settings", [$keyboard.Key.RightControl, $keyboard.Key.Enter], () => { + saveKeybind(); + }, { priority: $keyboard.PRIORITY.HIGH }); } + $keyboard.on("settings", [$keyboard.Key.Escape], () => { + if (!isKeybindInputFocused.value && !blurredByEscape.value) { + router.push("/"); + } + blurredByEscape.value = false; + }, { priority: $keyboard.PRIORITY.HIGH }); + + $keyboard.disableContext("main"); + $keyboard.enableContext("settings"); + autostart.value = (await $settings.getSetting("autostart")) === "true"; }); onUnmounted(() => { - keyboard.clear(); + $keyboard.disableContext("settings"); }); diff --git a/plugins/keyboard.ts b/plugins/keyboard.ts new file mode 100644 index 0000000..17e8d63 --- /dev/null +++ b/plugins/keyboard.ts @@ -0,0 +1,281 @@ +import { Key, keyboard } from "wrdu-keyboard"; +import { platform } from "@tauri-apps/plugin-os"; + +type KeyboardHandler = (event: KeyboardEvent) => void; + +const activeContexts = new Set(); +const handlersByContext: Record< + string, + Array<{ + keys: Key[]; + callback: KeyboardHandler; + prevent: boolean; + priority?: number; + }> +> = {}; + +const PRIORITY = { + HIGH: 100, + MEDIUM: 50, + LOW: 0, +}; + +let currentOS = "windows"; + +const useKeyboard = { + PRIORITY, + + registerContext: (contextName: string) => { + if (!handlersByContext[contextName]) { + handlersByContext[contextName] = []; + } + }, + + enableContext: (contextName: string) => { + if (!handlersByContext[contextName]) { + useKeyboard.registerContext(contextName); + } + activeContexts.add(contextName); + + initKeyboardHandlers(); + }, + + disableContext: (contextName: string) => { + activeContexts.delete(contextName); + + initKeyboardHandlers(); + }, + + on: ( + contextName: string, + keys: Key[], + callback: KeyboardHandler, + options: { prevent?: boolean; priority?: number } = {} + ) => { + if (!handlersByContext[contextName]) { + useKeyboard.registerContext(contextName); + } + + const existingHandlerIndex = handlersByContext[contextName].findIndex( + (handler) => + handler.keys.length === keys.length && + handler.keys.every((key, i) => key === keys[i]) && + handler.callback.toString() === callback.toString() + ); + + if (existingHandlerIndex !== -1) { + handlersByContext[contextName][existingHandlerIndex] = { + keys, + callback, + prevent: options.prevent ?? true, + priority: options.priority ?? PRIORITY.LOW, + }; + } else { + handlersByContext[contextName].push({ + keys, + callback, + prevent: options.prevent ?? true, + priority: options.priority ?? PRIORITY.LOW, + }); + } + + if (activeContexts.has(contextName)) { + initKeyboardHandlers(); + } + }, + + clearAll: () => { + keyboard.clear(); + }, + + setupAppShortcuts: (options: { + onNavigateUp?: () => void; + onNavigateDown?: () => void; + onSelect?: () => void; + onEscape?: () => void; + onToggleActions?: () => void; + contextName?: string; + priority?: number; + }) => { + const { + onNavigateUp, + onNavigateDown, + onSelect, + onEscape, + onToggleActions, + contextName = "app", + priority = PRIORITY.LOW, + } = options; + + if (!handlersByContext[contextName]) { + useKeyboard.registerContext(contextName); + } + + if (onNavigateUp) { + useKeyboard.on(contextName, [Key.UpArrow], () => onNavigateUp(), { + priority, + }); + } + + if (onNavigateDown) { + useKeyboard.on(contextName, [Key.DownArrow], () => onNavigateDown(), { + priority, + }); + } + + if (onSelect) { + useKeyboard.on(contextName, [Key.Enter], () => onSelect(), { priority }); + } + + if (onEscape) { + useKeyboard.on(contextName, [Key.Escape], () => onEscape(), { priority }); + } + + if (onToggleActions) { + const togglePriority = Math.max(priority, PRIORITY.HIGH); + + if (currentOS === "macos") { + useKeyboard.on( + contextName, + [Key.LeftMeta, Key.K], + () => onToggleActions(), + { priority: togglePriority } + ); + useKeyboard.on( + contextName, + [Key.RightMeta, Key.K], + () => onToggleActions(), + { priority: togglePriority } + ); + } else { + useKeyboard.on( + contextName, + [Key.LeftControl, Key.K], + () => onToggleActions(), + { priority: togglePriority } + ); + useKeyboard.on( + contextName, + [Key.RightControl, Key.K], + () => onToggleActions(), + { priority: togglePriority } + ); + } + } + }, + + setupKeybindCapture: (options: { + onCapture: (key: string) => void; + onComplete: () => void; + }) => { + const { onCapture, onComplete } = options; + + keyboard.prevent.down([Key.All], (event: KeyboardEvent) => { + if (event.code === "Escape") { + onComplete(); + return; + } + onCapture(event.code); + }); + }, +}; + +const initKeyboardHandlers = () => { + keyboard.clear(); + + let allHandlers: Array<{ + keys: Key[]; + callback: KeyboardHandler; + prevent: boolean; + priority: number; + contextName: string; + }> = []; + + for (const contextName of activeContexts) { + const handlers = handlersByContext[contextName] || []; + allHandlers = [ + ...allHandlers, + ...handlers.map((handler) => ({ + ...handler, + priority: handler.priority ?? PRIORITY.LOW, + contextName, + })), + ]; + } + + allHandlers.sort((a, b) => b.priority - a.priority); + + const handlersByKeyCombination: Record< + string, + Array<(typeof allHandlers)[0]> + > = {}; + + allHandlers.forEach((handler) => { + const keyCombo = handler.keys.sort().join("+"); + if (!handlersByKeyCombination[keyCombo]) { + handlersByKeyCombination[keyCombo] = []; + } + handlersByKeyCombination[keyCombo].push(handler); + }); + + Object.entries(handlersByKeyCombination).forEach(([_keyCombo, handlers]) => { + handlers.sort((a, b) => b.priority - a.priority); + const handler = handlers[0]; + + const wrappedCallback: KeyboardHandler = (event) => { + const isMetaCombo = + handler.keys.length > 1 && + (handler.keys.includes(Key.LeftMeta) || + handler.keys.includes(Key.RightMeta) || + handler.keys.includes(Key.LeftControl) || + handler.keys.includes(Key.RightControl)); + + const isNavigationKey = + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "Enter" || + event.key === "Escape"; + + const isInInput = + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement; + + if ( + (isMetaCombo || isNavigationKey || !isInInput) && + activeContexts.has(handler.contextName) + ) { + handler.callback(event); + } + }; + + if (handler.prevent) { + keyboard.prevent.down(handler.keys, wrappedCallback); + } else { + keyboard.down(handler.keys, wrappedCallback); + } + }); +}; + +export default defineNuxtPlugin(async (nuxtApp) => { + try { + const osName = platform(); + currentOS = osName.toLowerCase().includes("mac") ? "macos" : "windows"; + } catch (error) { + console.error("Error detecting platform:", error); + } + + initKeyboardHandlers(); + + nuxtApp.hook("page:finish", () => { + initKeyboardHandlers(); + }); + + return { + provide: { + keyboard: { + ...useKeyboard, + Key, + }, + }, + }; +}); diff --git a/plugins/selectedResult.ts b/plugins/selectedResult.ts new file mode 100644 index 0000000..d03babd --- /dev/null +++ b/plugins/selectedResult.ts @@ -0,0 +1,68 @@ +import { ref, computed } from 'vue'; +import type { HistoryItem } from '~/types/types'; + +interface GroupedHistory { + label: string; + items: HistoryItem[]; +} + +const selectedGroupIndex = ref(0); +const selectedItemIndex = ref(0); +const selectedElement = ref(null); + +const useSelectedResult = (groupedHistory: Ref) => { + const selectedItem = computed(() => { + const group = groupedHistory.value[selectedGroupIndex.value]; + return group?.items[selectedItemIndex.value] ?? undefined; + }); + + const isSelected = (groupIndex: number, itemIndex: number): boolean => { + return selectedGroupIndex.value === groupIndex && selectedItemIndex.value === itemIndex; + }; + + const selectNext = (): void => { + const currentGroup = groupedHistory.value[selectedGroupIndex.value]; + if (selectedItemIndex.value < currentGroup.items.length - 1) { + selectedItemIndex.value++; + } else if (selectedGroupIndex.value < groupedHistory.value.length - 1) { + selectedGroupIndex.value++; + selectedItemIndex.value = 0; + } + }; + + const selectPrevious = (): void => { + if (selectedItemIndex.value > 0) { + selectedItemIndex.value--; + } else if (selectedGroupIndex.value > 0) { + selectedGroupIndex.value--; + selectedItemIndex.value = groupedHistory.value[selectedGroupIndex.value].items.length - 1; + } + }; + + const selectItem = (groupIndex: number, itemIndex: number): void => { + selectedGroupIndex.value = groupIndex; + selectedItemIndex.value = itemIndex; + }; + + return { + selectedItem, + isSelected, + selectNext, + selectPrevious, + selectItem, + selectedElement + }; +}; + +export default defineNuxtPlugin(() => { + return { + provide: { + selectedResult: { + selectedGroupIndex, + selectedItemIndex, + selectedElement, + useSelectedResult + } + } + }; +}); \ No newline at end of file diff --git a/src-tauri/src/api/clipboard.rs b/src-tauri/src/api/clipboard.rs index f06881c..5a7d8d3 100644 --- a/src-tauri/src/api/clipboard.rs +++ b/src-tauri/src/api/clipboard.rs @@ -230,7 +230,6 @@ pub fn setup(app: &AppHandle) { } } - let _ = app_handle.emit("clipboard-content-updated", ()); let _ = app_handle.track_event( "clipboard_copied", Some( diff --git a/src-tauri/src/db/history.rs b/src-tauri/src/db/history.rs index 4f70310..a058eeb 100644 --- a/src-tauri/src/db/history.rs +++ b/src-tauri/src/db/history.rs @@ -5,6 +5,7 @@ use rand::distr::Alphanumeric; use sqlx::{ Row, SqlitePool }; use std::fs; use tauri_plugin_aptabase::EventTracker; +use tauri::Emitter; pub async fn initialize_history(pool: &SqlitePool) -> Result<(), Box> { let id: String = rng() @@ -71,8 +72,12 @@ pub async fn add_history_item( Some(_) => { sqlx ::query( - "UPDATE history SET timestamp = strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now') WHERE content = ? AND content_type = ?" + "UPDATE history SET source = ?, source_icon = ?, timestamp = strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'), favicon = ?, language = ? WHERE content = ? AND content_type = ?" ) + .bind(&source) + .bind(&source_icon) + .bind(&favicon) + .bind(&language) .bind(&content) .bind(&content_type) .execute(&*pool).await @@ -102,6 +107,8 @@ pub async fn add_history_item( "content_type": item.content_type.to_string() })) ); + + let _ = app_handle.emit("clipboard-content-updated", ()); Ok(()) } @@ -191,6 +198,7 @@ pub async fn delete_history_item( .map_err(|e| e.to_string())?; let _ = app_handle.track_event("history_item_deleted", None); + let _ = app_handle.emit("clipboard-content-updated", ()); Ok(()) } @@ -206,6 +214,7 @@ pub async fn clear_history( .map_err(|e| e.to_string())?; let _ = app_handle.track_event("history_cleared", None); + let _ = app_handle.emit("clipboard-content-updated", ()); Ok(()) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3629212..ff0d817 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -127,7 +127,8 @@ fn main() { db::history::read_image, db::settings::get_setting, db::settings::save_setting, - utils::commands::fetch_page_meta + utils::commands::fetch_page_meta, + utils::commands::get_app_info ] ) .run(tauri::generate_context!()) diff --git a/src-tauri/src/utils/commands.rs b/src-tauri/src/utils/commands.rs index ebf71b1..dca7528 100644 --- a/src-tauri/src/utils/commands.rs +++ b/src-tauri/src/utils/commands.rs @@ -36,31 +36,49 @@ pub fn center_window_on_current_monitor(window: &tauri::WebviewWindow) { } } +#[tauri::command] pub fn get_app_info() -> (String, Option) { println!("Getting app info"); let mut ctx = AppInfoContext::new(vec![]); println!("Created AppInfoContext"); - ctx.refresh_apps().unwrap(); + + if let Err(e) = ctx.refresh_apps() { + println!("Failed to refresh apps: {:?}", e); + return ("System".to_string(), None); + } + println!("Refreshed apps"); - match ctx.get_frontmost_application() { - Ok(window) => { - println!("Found frontmost application: {}", window.name); - let name = window.name.clone(); - let icon = window - .load_icon() - .ok() - .map(|i| { - println!("Loading icon for {}", name); - let png = i.to_png().unwrap(); - let encoded = STANDARD.encode(png.get_bytes()); - println!("Icon encoded successfully"); - encoded - }); - println!("Returning app info: {} with icon: {}", name, icon.is_some()); - (name, icon) + + let result = std::panic::catch_unwind(|| { + match ctx.get_frontmost_application() { + Ok(window) => { + println!("Found frontmost application: {}", window.name); + let name = window.name.clone(); + let icon = window + .load_icon() + .ok() + .and_then(|i| { + println!("Loading icon for {}", name); + i.to_png().ok().map(|png| { + let encoded = STANDARD.encode(png.get_bytes()); + println!("Icon encoded successfully"); + encoded + }) + }); + println!("Returning app info: {} with icon: {}", name, icon.is_some()); + (name, icon) + } + Err(e) => { + println!("Failed to get frontmost application: {:?}", e); + ("System".to_string(), None) + } } - Err(e) => { - println!("Failed to get frontmost application: {:?}", e); + }); + + match result { + Ok(info) => info, + Err(_) => { + println!("Panic occurred while getting app info"); ("System".to_string(), None) } } diff --git a/styles/index.scss b/styles/index.scss index 20a154d..2608ce2 100644 --- a/styles/index.scss +++ b/styles/index.scss @@ -138,7 +138,6 @@ main { justify-content: space-between; padding: 8px 0; border-bottom: 1px solid $divider; - line-height: 1; &:last-child { border-bottom: none; @@ -146,7 +145,7 @@ main { } &:first-child { - padding-top: 22px; + padding-top: 14px; } p {