Compare commits

...

21 commits

Author SHA1 Message Date
PandaDEV
2016e614ba
Merge ae5103e800 into 8adbeb1c6e 2025-04-15 22:39:51 +02:00
PandaDEV
8adbeb1c6e
Update README.md
Some checks failed
Nightly Builds / prepare (push) Has been cancelled
Nightly Builds / build-macos (arm64, --target aarch64-apple-darwin) (push) Has been cancelled
Nightly Builds / build-macos (x64, --target x86_64-apple-darwin) (push) Has been cancelled
Nightly Builds / build-windows (arm64, --target aarch64-pc-windows-msvc, aarch64-pc-windows-msvc) (push) Has been cancelled
Nightly Builds / build-windows (x64, --target x86_64-pc-windows-msvc, x86_64-pc-windows-msvc) (push) Has been cancelled
Nightly Builds / build-ubuntu (push) Has been cancelled
2025-04-15 20:42:15 +02:00
PandaDEV
ae5103e800
feat: update BottomBar component to include platform-specific key modifiers and improve action button layout 2025-03-16 23:38:57 +01:00
PandaDEV
8abf231912
feat: enhance keybind input handling with Escape key functionality and improved keyboard context management 2025-03-16 23:38:52 +01:00
PandaDEV
bbd7a54948
feat: add platform detection for keyboard functionality to support macOS and Windows 2025-03-16 23:29:38 +01:00
pandadev
3a5e2cba7e
feat: refactor ActionsMenu for improved accessibility and keyboard navigation, including focus management and enhanced keyboard shortcut handling 2025-03-16 23:00:48 +01:00
pandadev
b8238d01ca
feat: remove unused clipboard content update emission in setup function 2025-03-16 22:27:06 +01:00
pandadev
554943d349
feat: remove Cube icon component and enhance ActionsMenu with new keyboard shortcuts for toggling actions 2025-03-16 22:26:53 +01:00
pandadev
a79268d0f7
feat: enhance ActionsMenu functionality with improved keyboard context management and dynamic history updates 2025-03-16 22:26:28 +01:00
pandadev
be1718d9a5
feat: add new color variable for red and update SQL query in history management to include additional fields 2025-03-16 21:33:00 +01:00
pandadev
ddd92a70e2
feat: enhance ActionsMenu component with dynamic action icons, improved keyboard shortcuts, and integrated action handling logic 2025-03-16 21:32:55 +01:00
pandadev
b828daff08
feat: implement useAppControl composable for app hiding functionality and refactor index.vue to utilize it 2025-03-16 21:32:44 +01:00
pandadev
409e10a8fd
refactor: update BottomBar component to use new Key component for keyboard modifier representation 2025-03-16 21:32:27 +01:00
pandadev
843a1ea8b7
feat: add new key icon components for Cmd, Enter, Key, and Shift to enhance keyboard representation 2025-03-16 21:32:20 +01:00
pandadev
fce7eec1fc
feat: add new icon components including Bin, Board, Brush, Cube, Expand, Gear, Globe, Open, Pen, Rotate, T, and Zip; remove unused Cmd, Ctrl, Enter, Key, and Shift icons for a cleaner icon set 2025-03-16 21:32:14 +01:00
pandadev
2865f8749e
feat: add ActionsMenu component for enhanced action management with search functionality and keyboard navigation 2025-03-16 20:34:01 +01:00
pandadev
7ba418f4cc
refactor: update BottomBar component to improve action handling and add input support for secondary action 2025-03-16 20:33:51 +01:00
pandadev
b946b6455f
feat: implement keyboard context management and shortcuts for improved user interaction 2025-03-16 20:33:40 +01:00
pandadev
af64b77b74
refactor: move selectedResult logic to a new plugin for better organization and maintainability 2025-03-16 20:33:22 +01:00
pandadev
5e669749d7
feat: add get_app_info command with error handling and panic safety for improved app info retrieval 2025-03-16 20:33:00 +01:00
pandadev
dc638cb3ce
fix: adjust padding and line-height in main styles for better layout consistency 2025-03-16 11:58:34 +01:00
34 changed files with 1749 additions and 274 deletions

View file

@ -123,7 +123,7 @@ bun build
## 📝 License ## 📝 License
Qopy is licensed under GPL-3. See the [LICENSE file](./LICENCE) for more information. Qopy is licensed under AGPL-3. See the [LICENSE file](./LICENCE) for more information.
[codespaces-link]: https://codespaces.new/0pandadev/Qopy [codespaces-link]: https://codespaces.new/0pandadev/Qopy
[codespaces-shield]: https://github.com/codespaces/badge.svg [codespaces-shield]: https://github.com/codespaces/badge.svg

View file

@ -64,6 +64,8 @@ onMounted(async () => {
--accent: #feb453; --accent: #feb453;
--border: #ffffff0d; --border: #ffffff0d;
--red: #F84E4E;
--text: #e5dfd5; --text: #e5dfd5;
--text-secondary: #ada9a1; --text-secondary: #ada9a1;
--text-muted: #78756f; --text-muted: #78756f;

798
components/ActionsMenu.vue Normal file
View file

@ -0,0 +1,798 @@
<template>
<div
id="actions-menu"
class="actions-menu-overlay"
:class="{ visible: isVisible }"
@click="$emit('close')"
tabindex="-1">
<div class="actions-menu" @click.stop>
<div v-if="isVisible" class="actions" ref="menuRef">
<OverlayScrollbarsComponent ref="scrollbarsRef" class="actions-scrollable"
:options="{ scrollbars: { autoHide: 'scroll' } }">
<template v-if="searchQuery">
<div class="action-group">
<div v-if="allFilteredActions.length === 0" class="action no-results">
<div class="content">
<div class="title">No Results</div>
</div>
</div>
<div v-else v-for="(action, index) in allFilteredActions" :key="action.action" class="action"
@click="executeAction(action)" :class="{ selected: isSelected && currentIndex === index }" :ref="(el) => {
if (currentIndex === index) setSelectedElement(el);
}
" :style="action.color ? { color: action.color } : {}">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, keyIndex) in parseShortcut(action.shortcut)" :key="keyIndex">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
</div>
</template>
<template v-else>
<div class="action-group">
<div v-for="(action, index) in topActions" :key="action.action" class="action" @click="executeAction(action)"
:class="{
selected:
isSelected && currentIndex === getActionIndex(index, 'top'),
}" :ref="(el) => {
if (currentIndex === getActionIndex(index, 'top'))
setSelectedElement(el);
}
">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, index) in parseShortcut(action.shortcut)" :key="index">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
<div class="divider" v-if="
topActions.length > 0 && typeSpecificActions.length > 0
"></div>
</div>
<div v-if="typeSpecificActions.length > 0" class="action-group">
<div v-for="(action, index) in typeSpecificActions" :key="action.action" class="action"
@click="executeAction(action)" :class="{
selected:
isSelected &&
currentIndex === getActionIndex(index, 'specific'),
}" :ref="(el) => {
if (currentIndex === getActionIndex(index, 'specific'))
setSelectedElement(el);
}
">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, index) in parseShortcut(action.shortcut)" :key="index">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
<div class="divider" v-if="
typeSpecificActions.length > 0 && bottomActions.length > 0
"></div>
</div>
<div class="action-group">
<div v-for="(action, index) in bottomActions" :key="action.action" class="action"
@click="executeAction(action)" :class="{
selected:
isSelected && currentIndex === getActionIndex(index, 'bottom'),
}" :ref="(el) => {
if (currentIndex === getActionIndex(index, 'bottom'))
setSelectedElement(el);
}
" :style="action.color ? { color: action.color } : {}">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, index) in parseShortcut(action.shortcut)" :key="index">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
</div>
</template>
</OverlayScrollbarsComponent>
<input type="text" v-model="searchQuery" class="search-input" placeholder="Search..." @keydown="handleSearchKeydown"
ref="searchInput" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, nextTick, h } from "vue";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import "overlayscrollbars/overlayscrollbars.css";
import Enter from "./Keys/Enter.vue";
import Cmd from "./Keys/Cmd.vue";
import Key from "./Keys/Key.vue";
import { ContentType, HistoryItem } from "../types/types";
import { invoke } from "@tauri-apps/api/core";
import { useNuxtApp } from "#app";
import Shift from "./Keys/Shift.vue";
import Gear from "./Icons/Gear.vue";
import Bin from "./Icons/Bin.vue";
import Pen from "./Icons/Pen.vue";
import T from "./Icons/T.vue";
import Board from "./Icons/Board.vue";
import Open from "./Icons/Open.vue";
import Globe from "./Icons/Globe.vue";
import Zip from "./Icons/Zip.vue";
import Brush from "./Icons/Brush.vue";
import Rotate from "./Icons/Rotate.vue";
import Expand from "./Icons/Expand.vue";
import { useActions } from "../composables/useActions";
interface AppInfo {
name: string;
icon?: string;
}
const currentAppInfo = ref<AppInfo>({ name: "Current App" });
const isSelected = ref(true);
const currentIndex = ref(0);
const selectedElement = ref<HTMLElement | null>(null);
const searchQuery = ref("");
const searchInput = ref<HTMLInputElement | null>(null);
const { $keyboard } = useNuxtApp();
const menuRef = ref<HTMLElement | null>(null);
const scrollbarsRef = ref<InstanceType<
typeof OverlayScrollbarsComponent
> | null>(null);
const { handleAction } = useActions();
const SCROLL_PADDING = 8;
const setSelectedElement = (el: any) => {
if (el && el instanceof HTMLElement) {
selectedElement.value = el;
}
};
const getAppInfo = async () => {
try {
const appInfo = await invoke("get_app_info");
if (appInfo && typeof appInfo === "object" && "name" in appInfo) {
currentAppInfo.value = appInfo as AppInfo;
}
} catch (error) {
console.error("Failed to get app info:", error);
}
};
const props = defineProps<{
isVisible: boolean;
selectedItem?: HistoryItem;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "toggle"): void;
(e: "action", action: string, item?: HistoryItem): void;
}>();
interface ActionItem {
title: string;
action: string;
shortcut?: string;
icon?: any;
group: string;
color?: string;
}
const topActions = computed((): ActionItem[] => [
{
title: `Paste to ${currentAppInfo.value.name || "Current App"}`,
shortcut: "Enter",
action: "paste-to-app",
group: "top",
icon: currentAppInfo.value.icon ? {
render() {
return h('img', {
src: currentAppInfo.value.icon,
style: {
width: '14px',
height: '14px',
objectFit: 'contain'
}
});
}
} : undefined,
},
{
title: "Copy to Clipboard",
shortcut: "Ctrl+C",
action: "copy",
group: "top",
icon: Board,
},
]);
const bottomActions = computed((): ActionItem[] => [
{
title: "Delete Entry",
shortcut: "Alt+X",
action: "delete",
group: "bottom",
icon: Bin,
color: "var(--red)"
},
{
title: "Delete All Entries",
shortcut: "Alt+Shift+X",
action: "delete-all",
group: "bottom",
icon: Bin,
color: "var(--red)"
},
{
title: "Settings",
shortcut: "Ctrl+S",
action: "settings",
group: "bottom",
icon: Gear,
},
]);
const textActions = computed((): ActionItem[] => [
{
title: "Paste as plain text",
action: "paste-plain",
shortcut: "Ctrl+Shift+V",
group: "text",
icon: T,
},
{
title: "Edit text",
action: "edit-text",
shortcut: "Ctrl+E",
group: "text",
icon: Pen,
},
]);
const imageActions = computed((): ActionItem[] => [
{
title: "Rotate",
action: "rotate-image",
shortcut: "Alt+R",
group: "image",
icon: Rotate,
},
{
title: "Resize",
action: "resize-image",
shortcut: "Alt+S",
group: "image",
icon: Expand,
},
{
title: "Compress",
action: "compress-image",
shortcut: "Alt+C",
group: "image",
icon: Zip,
},
]);
const fileActions = computed((): ActionItem[] => [
{
title: "Open",
action: "open-file",
shortcut: "Ctrl+O",
group: "file",
icon: Open,
},
{
title: "Compress to zip",
action: "compress-file",
shortcut: "Alt+C",
group: "file",
icon: Zip,
},
]);
const linkActions = computed((): ActionItem[] => [
{
title: "Open in Browser",
action: "open-link",
shortcut: "Ctrl+O",
group: "link",
icon: Globe,
},
]);
const colorActions = computed((): ActionItem[] => [
{
title: "Copy as HEX",
action: "copy-hex",
shortcut: "Alt+H",
group: "color",
icon: Brush,
},
{
title: "Copy as RGB(a)",
action: "copy-rgba",
shortcut: "Alt+R",
group: "color",
icon: Brush,
},
{
title: "Copy as HSL(a)",
action: "copy-hsla",
shortcut: "Alt+S",
group: "color",
icon: Brush,
},
]);
const typeSpecificActions = computed(() => {
if (!props.selectedItem) return [];
switch (props.selectedItem.content_type) {
case ContentType.Text:
return textActions.value;
case ContentType.Image:
return imageActions.value;
case ContentType.File:
return fileActions.value;
case ContentType.Link:
return linkActions.value;
case ContentType.Color:
return colorActions.value;
case ContentType.Code:
return textActions.value;
default:
return [];
}
});
const allActions = computed(() => {
return [
...topActions.value,
...typeSpecificActions.value,
...bottomActions.value,
];
});
const allFilteredActions = computed(() => {
if (!searchQuery.value) return allActions.value;
return allActions.value.filter((action) =>
action.title.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const getActionIndex = (
index: number,
group: "top" | "specific" | "bottom"
): number => {
if (group === "top") {
return index;
} else if (group === "specific") {
return topActions.value.length + index;
} else {
return topActions.value.length + typeSpecificActions.value.length + index;
}
};
interface KeyPart {
type: "modifier" | "key" | "separator";
value: string;
component?: any;
}
const parseShortcut = (shortcut: string): KeyPart[] => {
const parts = shortcut.split("+");
const result: KeyPart[] = [];
parts.forEach((part, index) => {
const trimmedPart = part.trim();
let keyPart: KeyPart;
if (trimmedPart.toLowerCase() === "cmd") {
keyPart = { type: "modifier", value: trimmedPart, component: Cmd };
} else if (trimmedPart.toLowerCase() === "shift") {
keyPart = { type: "modifier", value: trimmedPart, component: Shift };
} else if (trimmedPart.toLowerCase() === "enter") {
keyPart = { type: "key", value: trimmedPart, component: Enter };
} else {
keyPart = { type: "key", value: trimmedPart, component: Key };
}
result.push(keyPart);
if (index < parts.length - 1) {
result.push({ type: "separator", value: "+" });
}
});
return result;
};
const executeAction = (action: ActionItem) => {
emit("close");
handleAction(action.action, props.selectedItem);
};
const close = () => {
emit("close");
};
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
close();
}
};
const handleWindowBlur = () => {
close();
};
const setupKeyboardHandlers = () => {
$keyboard.on(
"actionsMenu",
[$keyboard.Key.ArrowDown],
(event) => {
event.preventDefault();
selectNext();
},
{ priority: $keyboard.PRIORITY.HIGH }
);
$keyboard.on(
"actionsMenu",
[$keyboard.Key.ArrowUp],
(event) => {
event.preventDefault();
selectPrevious();
},
{ priority: $keyboard.PRIORITY.HIGH }
);
$keyboard.on(
"actionsMenu",
[$keyboard.Key.Enter],
(event) => {
event.preventDefault();
if (searchQuery.value) {
const action = allFilteredActions.value[currentIndex.value];
if (action) executeAction(action);
} else {
let action;
if (currentIndex.value < topActions.value.length) {
action = topActions.value[currentIndex.value];
} else if (
currentIndex.value <
topActions.value.length + typeSpecificActions.value.length
) {
action =
typeSpecificActions.value[
currentIndex.value - topActions.value.length
];
} else {
action =
bottomActions.value[
currentIndex.value -
topActions.value.length -
typeSpecificActions.value.length
];
}
if (action) executeAction(action);
}
},
{ priority: $keyboard.PRIORITY.HIGH }
);
$keyboard.on(
"actionsMenu",
[$keyboard.Key.Escape],
(event) => {
event.preventDefault();
close();
},
{ priority: $keyboard.PRIORITY.HIGH }
);
$keyboard.on(
"actionsMenu",
[$keyboard.Key.LeftControl, $keyboard.Key.K],
(event) => {
event.preventDefault();
emit("toggle");
},
{ priority: $keyboard.PRIORITY.HIGH }
);
$keyboard.on(
"actionsMenu",
[$keyboard.Key.RightControl, $keyboard.Key.K],
(event) => {
event.preventDefault();
emit("toggle");
},
{ priority: $keyboard.PRIORITY.HIGH }
);
$keyboard.on(
"actionsMenu",
[$keyboard.Key.MetaLeft, $keyboard.Key.K],
(event) => {
event.preventDefault();
emit("toggle");
},
{ priority: $keyboard.PRIORITY.HIGH }
);
$keyboard.on(
"actionsMenu",
[$keyboard.Key.MetaRight, $keyboard.Key.K],
(event) => {
event.preventDefault();
emit("toggle");
},
{ priority: $keyboard.PRIORITY.HIGH }
);
};
const selectNext = () => {
if (searchQuery.value) {
if (allFilteredActions.value.length === 0) return;
currentIndex.value =
(currentIndex.value + 1) % allFilteredActions.value.length;
} else {
const totalActions = allActions.value.length;
if (totalActions === 0) return;
currentIndex.value = (currentIndex.value + 1) % totalActions;
}
scrollToSelected();
};
const selectPrevious = () => {
if (searchQuery.value) {
if (allFilteredActions.value.length === 0) return;
currentIndex.value =
(currentIndex.value - 1 + allFilteredActions.value.length) %
allFilteredActions.value.length;
} else {
const totalActions = allActions.value.length;
if (totalActions === 0) return;
currentIndex.value = (currentIndex.value - 1 + totalActions) % totalActions;
}
scrollToSelected();
};
const scrollToSelected = () => {
nextTick(() => {
if (!selectedElement.value) return;
if (!scrollbarsRef.value) return;
const viewport = scrollbarsRef.value.osInstance()?.elements().viewport;
if (!viewport) {
selectedElement.value.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
return;
}
setTimeout(() => {
if (!selectedElement.value) return;
const viewportRect = viewport.getBoundingClientRect();
const elementRect = selectedElement.value.getBoundingClientRect();
const isAbove = elementRect.top < viewportRect.top + SCROLL_PADDING;
const isBelow = elementRect.bottom > viewportRect.bottom - SCROLL_PADDING;
if (isAbove) {
const scrollAmount =
viewport.scrollTop +
(elementRect.top - viewportRect.top) -
SCROLL_PADDING;
viewport.scrollTo({
top: scrollAmount,
behavior: "smooth",
});
} else if (isBelow) {
const scrollAmount =
viewport.scrollTop +
(elementRect.bottom - viewportRect.bottom) +
SCROLL_PADDING;
viewport.scrollTo({
top: scrollAmount,
behavior: "smooth",
});
}
}, 10);
});
};
const handleSearchKeydown = (event: KeyboardEvent) => {
if (
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter" ||
event.key === "Escape"
) {
return;
}
if (event.key.toLowerCase() === "k" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
event.stopPropagation();
emit("toggle");
return;
}
event.stopPropagation();
};
watch(
() => props.isVisible,
(visible) => {
if (visible) {
currentIndex.value = 0;
searchQuery.value = "";
setupKeyboardHandlers();
$keyboard.enableContext("actionsMenu");
nextTick(() => {
if (searchInput.value) {
setTimeout(() => {
searchInput.value?.focus();
}, 50);
}
});
} else {
$keyboard.disableContext("actionsMenu");
}
}
);
watch(searchQuery, (query) => {
currentIndex.value = 0;
});
watch(
[allFilteredActions, topActions, typeSpecificActions, bottomActions],
() => {
if (searchQuery.value) {
if (
currentIndex.value >= allFilteredActions.value.length &&
allFilteredActions.value.length > 0
) {
currentIndex.value = 0;
}
} else {
const totalActions = allActions.value.length;
if (currentIndex.value >= totalActions && totalActions > 0) {
currentIndex.value = 0;
}
}
}
);
onMounted(() => {
document.addEventListener("click", handleClickOutside);
window.addEventListener("blur", handleWindowBlur);
getAppInfo();
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
window.removeEventListener("blur", handleWindowBlur);
$keyboard.disableContext("actionsMenu");
});
</script>
<style scoped lang="scss">
.actions {
border-radius: 8px;
border: 1px solid var(--border);
background-color: var(--background);
position: fixed;
bottom: 48px;
right: 8px;
z-index: 100;
width: 350px;
max-height: 250px;
height: auto;
display: flex;
flex-direction: column;
color: var(--text);
}
.actions-scrollable {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.action-group {
display: flex;
flex-direction: column;
padding-inline: 8px;
}
.divider {
height: 1px;
background-color: var(--border);
margin: 8px -8px;
width: calc(100% + 16px);
}
.action.no-results {
justify-content: center;
text-align: center;
color: var(--text-secondary);
width: 100%;
}
.action {
display: flex;
justify-content: space-between;
align-items: center;
padding-inline: 8px;
height: 36px;
cursor: pointer;
border-radius: 5px;
&.selected {
background-color: var(--border);
}
.shortcut {
display: flex;
gap: 2px;
height: 20px;
}
.content {
display: flex;
align-items: center;
gap: 8px;
.icon {
width: 14px;
height: 14px;
}
.title {
color: inherit;
}
}
}
.search-input {
width: 100%;
padding: 12px 16px;
outline: none;
border: none;
border-top: 1px solid var(--border);
background-color: var(--background);
color: var(--text);
font-size: 14px;
border-bottom-left-radius: 7px;
border-bottom-right-radius: 7px;
margin-top: auto;
}
</style>

View file

@ -5,17 +5,21 @@
<p class="name">Qopy</p> <p class="name">Qopy</p>
</div> </div>
<div class="buttons"> <div class="buttons">
<div v-if="primaryAction" class="paste" @click="primaryAction.onClick"> <div v-if="primaryAction" class="paste" @click="handlePrimaryClick">
<p class="text">{{ primaryAction.text }}</p> <p class="text">{{ primaryAction.text }}</p>
<component :is="primaryAction.icon" /> <div class="keys">
<Key v-if="(os === 'windows' || os === 'linux') && primaryAction.showModifier" :input="'Ctrl'" />
<IconsCmd v-if="os === 'macos' && primaryAction.showModifier" />
<component :is="primaryAction.icon" :input="primaryAction.input" />
</div>
</div> </div>
<div v-if="secondaryAction" class="divider"></div> <div v-if="secondaryAction" class="divider"></div>
<div v-if="secondaryAction" class="actions" @click="secondaryAction.onClick"> <div v-if="secondaryAction" class="actions" @click="handleSecondaryClick">
<p class="text">{{ secondaryAction.text }}</p> <p class="text">{{ secondaryAction.text }}</p>
<div> <div class="keys">
<IconsCtrl v-if="(os === 'windows' || os === 'linux') && secondaryAction.showModifier" /> <Key v-if="(os === 'windows' || os === 'linux') && secondaryAction.showModifier" :input="'Ctrl'" />
<IconsCmd v-if="os === 'macos' && secondaryAction.showModifier" /> <IconsCmd v-if="os === 'macos' && secondaryAction.showModifier" />
<component :is="secondaryAction.icon" /> <component :is="secondaryAction.icon" :input="secondaryAction.input" />
</div> </div>
</div> </div>
</div> </div>
@ -23,13 +27,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue';
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import IconsCmd from './Keys/Cmd.vue';
import Key from './Keys/Key.vue';
interface Action { interface Action {
text: string; text: string;
icon: any; icon: any;
onClick?: () => void; onClick?: () => void;
showModifier?: boolean; showModifier?: boolean;
input?: string;
} }
const props = defineProps<{ const props = defineProps<{
@ -39,8 +47,22 @@ const props = defineProps<{
const os = ref<string>(""); const os = ref<string>("");
onMounted(() => { const handlePrimaryClick = (event: MouseEvent) => {
os.value = platform(); event.stopPropagation();
if (props.primaryAction?.onClick) {
props.primaryAction.onClick();
}
};
const handleSecondaryClick = (event: MouseEvent) => {
event.stopPropagation();
if (props.secondaryAction?.onClick) {
props.secondaryAction.onClick();
}
};
onMounted(async () => {
os.value = await platform();
}); });
</script> </script>
@ -78,7 +100,7 @@ onMounted(() => {
color: var(--text); color: var(--text);
} }
.actions div { .keys {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
@ -111,6 +133,12 @@ onMounted(() => {
background-color: var(--border); background-color: var(--border);
} }
.paste:active,
.actions:active {
background-color: var(--border-active, #444);
transform: scale(0.98);
}
&:hover .paste:hover ~ .divider, &:hover .paste:hover ~ .divider,
&:hover .divider:has(+ .actions:hover) { &:hover .divider:has(+ .actions:hover) {
opacity: 0; opacity: 0;

7
components/Icons/Bin.vue Normal file
View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 16 16">
<path fill="#F84E4E" fill-rule="evenodd"
d="M9 2H7a.5.5 0 0 0-.5.5V3h3v-.5A.5.5 0 0 0 9 2m2 1v-.5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2V3H2.251a.75.75 0 0 0 0 1.5h.312l.317 7.625A3 3 0 0 0 5.878 15h4.245a3 3 0 0 0 2.997-2.875l.318-7.625h.312a.75.75 0 0 0 0-1.5zm.936 1.5H4.064l.315 7.562A1.5 1.5 0 0 0 5.878 13.5h4.245a1.5 1.5 0 0 0 1.498-1.438zm-6.186 2v5a.75.75 0 0 0 1.5 0v-5a.75.75 0 0 0-1.5 0m3.75-.75a.75.75 0 0 1 .75.75v5a.75.75 0 0 1-1.5 0v-5a.75.75 0 0 1 .75-.75"
clip-rule="evenodd" />
</svg>
</template>

View file

@ -0,0 +1,14 @@
<template>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<path
d="M0 1.38272C0 0.619063 0.596954 0 1.33333 0C1.33333 0 2.66667 0 2.66667 0C3.40305 0 4 0.619063 4 1.38272C4 1.38272 4 1.38272 4 1.38272C4 2.14637 3.40305 2.76543 2.66667 2.76543C2.66667 2.76543 1.33333 2.76543 1.33333 2.76543C0.596954 2.76543 0 2.14637 0 1.38272"
fill="none" stroke-width="1.5" stroke="#E5DFD5" stroke-linecap="round" stroke-linejoin="round"
transform="translate(5 0.778)" />
<path
d="M2.66667 0C2.66667 0 1.33333 0 1.33333 0C0.596954 0 0 0.619063 0 1.38272C0 1.38272 0 9.67901 0 9.67901C0 10.4427 0.596954 11.0617 1.33333 11.0617C1.33333 11.0617 8 11.0617 8 11.0617C8.73638 11.0617 9.33333 10.4427 9.33333 9.67901C9.33333 9.67901 9.33333 1.38272 9.33333 1.38272C9.33333 0.619063 8.73638 0 8 0C8 0 6.66667 0 6.66667 0"
fill="none" stroke-width="1.5" stroke="#E5DFD5" stroke-linecap="round" stroke-linejoin="round"
transform="translate(2.333 2.161)" />
</g>
</svg>
</template>

View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 16 16">
<path fill="#E5DFD5" fill-rule="evenodd"
d="M8.922 9.842q.077.425.078.896c0 1.907-1.387 3.66-3.79 3.894a4.78 4.78 0 0 1-4.208-1.774a2 2 0 0 1-.21-.333c-.231-.461-.292-1-.292-1.528c.312.047.599.045.852 0c.635-.112 1.061-.487 1.148-1C2.73 8.637 3.572 7 5.76 7q.224 0 .435.028l3.417-4.784a2.971 2.971 0 1 1 4.145 4.145zm-.56-1.444l2.819-2.013A2.7 2.7 0 0 0 9.615 4.82L7.626 7.605q.43.324.737.793m4.066-2.904l.457-.326a1.471 1.471 0 1 0-2.052-2.052l-.326.457a4.2 4.2 0 0 1 1.921 1.921M3.98 10.247c.086-.507.272-.962.54-1.264c.225-.254.572-.483 1.242-.483c.517 0 .913.197 1.198.523c.297.34.541.906.541 1.715c0 1.121-.786 2.24-2.435 2.4a3.3 3.3 0 0 1-2.63-.922c.76-.337 1.374-.965 1.544-1.969"
clip-rule="evenodd" />
</svg>
</template>

View file

@ -1,32 +0,0 @@
<template>
<svg
width="24px"
height="20px"
viewBox="0 0 24 20"
version="1.1"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg">
<g id="Ctrl" fill-opacity="1">
<path
d="M-751 -2016L-751 -2016L-751 -1996L-775 -1996L-775 -2016L-751 -2016Z"
id="Ctrl"
fill="none"
stroke="none" />
<path
d="M12 0L17.6 0C19.8402 0 20.9603 0 21.816 0.435974Q22.3804 0.723593 22.8284 1.17157Q23.2764 1.61955 23.564 2.18404C24 3.03969 24 4.15979 24 6.4L24 13.6C24 15.8402 24 16.9603 23.564 17.816Q23.2764 18.3804 22.8284 18.8284Q22.3804 19.2764 21.816 19.564C20.9603 20 19.8402 20 17.6 20L6.4 20C4.15979 20 3.03969 20 2.18404 19.564Q1.61955 19.2764 1.17157 18.8284Q0.723594 18.3804 0.435974 17.816C0 16.9603 0 15.8402 0 13.6L0 6.4C0 4.15979 0 3.03969 0.435974 2.18404Q0.723594 1.61955 1.17157 1.17157Q1.61955 0.723594 2.18404 0.435974C3.03969 0 4.15979 0 6.4 0L12 0Z"
id="Rectangle"
fill="#FFFFFF"
fill-opacity="0.050980393"
stroke="none" />
<path
d="M7.97095 9.00977L12 5L16 9.00977"
id="Vector"
fill="none"
fill-rule="evenodd"
stroke="#E5E0D5"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round" />
</g>
</svg>
</template>

View file

@ -0,0 +1,10 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 16 16">
<g fill="none">
<path fill="#E5DFD5" fill-rule="evenodd"
d="M7.47 1.22a.75.75 0 0 1 1.06 0l1.75 1.75a.75.75 0 1 1-1.06 1.06l-.47-.47v8.88l.47-.47a.75.75 0 1 1 1.06 1.06l-1.75 1.75a.75.75 0 0 1-1.06 0l-1.75-1.75a.75.75 0 1 1 1.06-1.06l.47.47V3.56l-.47.47a.75.75 0 0 1-1.06-1.06zM1.22 7.47a.75.75 0 0 0 0 1.06l1.75 1.75a.75.75 0 1 0 1.06-1.06L2.81 8l1.22-1.22a.75.75 0 0 0-1.06-1.06zm13.56 1.06l-1.75 1.75a.75.75 0 1 1-1.06-1.06L13.19 8l-1.22-1.22a.75.75 0 0 1 1.06-1.06l1.75 1.75a.75.75 0 0 1 0 1.06"
clip-rule="evenodd" />
<path stroke="#E5DFD5" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.5 8h11" />
</g>
</svg>
</template>

View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 16 16">
<path fill="#E5DFD5" fill-rule="evenodd"
d="M7.199 2H8.8a.2.2 0 0 1 .2.2c0 1.808 1.958 2.939 3.524 2.034a.2.2 0 0 1 .271.073l.802 1.388a.2.2 0 0 1-.073.272c-1.566.904-1.566 3.164 0 4.069a.2.2 0 0 1 .073.271l-.802 1.388a.2.2 0 0 1-.271.073C10.958 10.863 9 11.993 9 13.8a.2.2 0 0 1-.199.2H7.2a.2.2 0 0 1-.2-.2c0-1.808-1.958-2.938-3.524-2.034a.2.2 0 0 1-.272-.073l-.8-1.388a.2.2 0 0 1 .072-.271c1.566-.905 1.566-3.165 0-4.07a.2.2 0 0 1-.073-.27l.801-1.389a.2.2 0 0 1 .272-.072C5.042 5.138 7 4.007 7 2.199c0-.11.089-.199.199-.199M5.5 2.2c0-.94.76-1.7 1.699-1.7H8.8c.94 0 1.7.76 1.7 1.7a.85.85 0 0 0 1.274.735a1.7 1.7 0 0 1 2.32.622l.802 1.388c.469.813.19 1.851-.622 2.32a.85.85 0 0 0 0 1.472a1.7 1.7 0 0 1 .622 2.32l-.802 1.388a1.7 1.7 0 0 1-2.32.622a.85.85 0 0 0-1.274.735c0 .939-.76 1.7-1.699 1.7H7.2a1.7 1.7 0 0 1-1.699-1.7a.85.85 0 0 0-1.274-.735a1.7 1.7 0 0 1-2.32-.622l-.802-1.388a1.7 1.7 0 0 1 .622-2.32a.85.85 0 0 0 0-1.471a1.7 1.7 0 0 1-.622-2.32l.801-1.389a1.7 1.7 0 0 1 2.32-.622A.85.85 0 0 0 5.5 2.2m4 5.8a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0M11 8a3 3 0 1 1-6 0a3 3 0 0 1 6 0"
clip-rule="evenodd" />
</svg>
</template>

View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 16 16">
<path fill="#E5DFD5" fill-rule="evenodd"
d="M9.208 12.346c-.485 1-.953 1.154-1.208 1.154s-.723-.154-1.208-1.154c-.372-.768-.647-1.858-.749-3.187a21 21 0 0 0 3.914 0c-.102 1.329-.377 2.419-.75 3.187m.788-4.699C9.358 7.714 8.69 7.75 8 7.75s-1.358-.036-1.996-.103c.037-1.696.343-3.075.788-3.993C7.277 2.654 7.745 2.5 8 2.5s.723.154 1.208 1.154c.445.918.75 2.297.788 3.993m1.478 1.306c-.085 1.516-.375 2.848-.836 3.874a5.5 5.5 0 0 0 2.843-4.364c-.621.199-1.295.364-2.007.49m1.918-2.043c-.572.204-1.21.379-1.901.514c-.056-1.671-.354-3.14-.853-4.251a5.5 5.5 0 0 1 2.754 3.737m-8.883.514c.056-1.671.354-3.14.853-4.251A5.5 5.5 0 0 0 2.608 6.91c.572.204 1.21.379 1.901.514M2.52 8.463a5.5 5.5 0 0 0 2.843 4.364c-.46-1.026-.75-2.358-.836-3.874a15.5 15.5 0 0 1-2.007-.49M15 8A7 7 0 1 0 1 8a7 7 0 0 0 14 0"
clip-rule="evenodd" />
</svg>
</template>

View file

@ -1,29 +0,0 @@
<template>
<svg
width="24px"
height="20px"
viewBox="0 0 24 20"
version="1.1"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg">
<g id="K" fill-opacity="1">
<path
d="M-751 -2016L-751 -2016L-751 -1996L-775 -1996L-775 -2016L-751 -2016Z"
id="K"
fill="none"
stroke="none" />
<path
d="M12 0L17.6 0C19.8402 0 20.9603 0 21.816 0.435974Q22.3804 0.723593 22.8284 1.17157Q23.2764 1.61955 23.564 2.18404C24 3.03969 24 4.15979 24 6.4L24 13.6C24 15.8402 24 16.9603 23.564 17.816Q23.2764 18.3804 22.8284 18.8284Q22.3804 19.2764 21.816 19.564C20.9603 20 19.8402 20 17.6 20L6.4 20C4.15979 20 3.03969 20 2.18404 19.564Q1.61955 19.2764 1.17157 18.8284Q0.723594 18.3804 0.435974 17.816C0 16.9603 0 15.8402 0 13.6L0 6.4C0 4.15979 0 3.03969 0.435974 2.18404Q0.723594 1.61955 1.17157 1.17157Q1.61955 0.723594 2.18404 0.435974C3.03969 0 4.15979 0 6.4 0L12 0Z"
id="Rectangle"
fill="#FFFFFF"
fill-opacity="0.050980393"
stroke="none" />
<g id="K" transform="translate(8 0)">
<g transform="translate(0, 2.8945312)" id="K" fill="#E5E0D5">
<path
d="M1.5 11.5254C1.97461 11.5254 2.25586 11.2383 2.25586 10.7402L2.25586 8.70703L3.13477 7.77539L5.87695 11.127C6.11133 11.4199 6.31641 11.5313 6.61523 11.5313C7.02539 11.5313 7.3418 11.2148 7.3418 10.8047C7.3418 10.6055 7.25391 10.3945 7.04883 10.1426L4.25391 6.76172L6.79688 4.10742C6.97266 3.91992 7.04297 3.76172 7.04297 3.55664C7.04297 3.16992 6.74414 2.86523 6.33984 2.86523C6.09375 2.86523 5.91211 2.95898 5.70703 3.18164L2.30859 6.84961L2.25586 6.84961L2.25586 3.65625C2.25586 3.1582 1.97461 2.87109 1.5 2.87109C1.03125 2.87109 0.744141 3.1582 0.744141 3.65625L0.744141 10.7402C0.744141 11.2383 1.03125 11.5254 1.5 11.5254Z" />
</g>
</g>
</g>
</svg>
</template>

View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 16 16">
<path fill="#E5DFD5" fill-rule="evenodd"
d="M10 1.5A.75.75 0 0 0 10 3h1.94L6.97 7.97a.75.75 0 0 0 1.06 1.06L13 4.06V6a.75.75 0 0 0 1.5 0V2.25a.75.75 0 0 0-.75-.75zM7.5 3.25a.75.75 0 0 0-.75-.75H4.5a3 3 0 0 0-3 3v6a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V9.25a.75.75 0 0 0-1.5 0v2.25a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 3 11.5v-6A1.5 1.5 0 0 1 4.5 4h2.25a.75.75 0 0 0 .75-.75"
clip-rule="evenodd" />
</svg>
</template>

7
components/Icons/Pen.vue Normal file
View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 16 16">
<path fill="#E5DFD5" fill-rule="evenodd"
d="M11.423 1A3.577 3.577 0 0 1 15 4.577c0 .27-.108.53-.3.722l-.528.529l-1.971 1.971l-5.059 5.059a3 3 0 0 1-1.533.82l-2.638.528a1 1 0 0 1-1.177-1.177l.528-2.638a3 3 0 0 1 .82-1.533l5.059-5.059l2.5-2.5c.191-.191.451-.299.722-.299m-2.31 4.009l-4.91 4.91a1.5 1.5 0 0 0-.41.766l-.38 1.903l1.902-.38a1.5 1.5 0 0 0 .767-.41l4.91-4.91a2.08 2.08 0 0 0-1.88-1.88m3.098.658a3.6 3.6 0 0 0-1.878-1.879l1.28-1.28c.995.09 1.788.884 1.878 1.88z"
clip-rule="evenodd" />
</svg>
</template>

View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 16 16">
<path fill="#E5DFD5" fill-rule="evenodd"
d="M8 1.5a6.5 6.5 0 1 0 6.445 7.348a.75.75 0 1 0-1.487-.194A5.001 5.001 0 1 1 11.57 4.5h-1.32a.75.75 0 0 0 0 1.5h3a.75.75 0 0 0 .75-.75v-3a.75.75 0 0 0-1.5 0v1.06A6.48 6.48 0 0 0 8 1.5"
clip-rule="evenodd" />
</svg>
</template>

7
components/Icons/T.vue Normal file
View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 16 16">
<path fill="#E5DFD5" fill-rule="evenodd"
d="M3.279 2.544A.75.75 0 0 1 4 2h8a.75.75 0 0 1 .721.544l.5 1.75a.75.75 0 1 1-1.442.412L11.434 3.5H8.75l-.004 9H9.5a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5h.746l.004-9H4.566L4.22 4.706a.75.75 0 1 1-1.442-.412z"
clip-rule="evenodd" />
</svg>
</template>

7
components/Icons/Zip.vue Normal file
View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 16 16">
<path fill="#E5DFD5" fill-rule="evenodd"
d="M11 13.5H5A1.5 1.5 0 0 1 3.5 12V4A1.5 1.5 0 0 1 5 2.5h.5v.75c0 .414.336.75.75.75H7v2h-.75a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75H7v2h-.75a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75H7v-2h.75a.75.75 0 0 0 .75-.75v-.5A.75.75 0 0 0 7.75 8H7V6h.75a.75.75 0 0 0 .75-.75v-.5A.75.75 0 0 0 7.75 4H7V2.5h.757a1.5 1.5 0 0 1 1.061.44l3.243 3.242a1.5 1.5 0 0 1 .439 1.06V12a1.5 1.5 0 0 1-1.5 1.5m2.121-8.379A3 3 0 0 1 14 7.243V12a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V4a3 3 0 0 1 3-3h2.757a3 3 0 0 1 2.122.879z"
clip-rule="evenodd" />
</svg>
</template>

30
components/Keys/Key.vue Normal file
View file

@ -0,0 +1,30 @@
<template>
<div
class="key-container"
:style="{
backgroundColor: 'var(--border)',
padding: '0 7px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '4px',
minWidth: '22px'
}"
>
<span
:style="{
color: '#E5E0D5',
fontSize: '12px'
}"
>
{{ input }}
</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
input: string;
}>();
</script>

12
components/Keys/Shift.vue Normal file
View file

@ -0,0 +1,12 @@
<template>
<svg width="24" height="20" viewBox="0 0 24 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<path
d="M12 0L17.6 0C19.8402 0 20.9603 0 21.816 0.435974Q22.3804 0.723593 22.8284 1.17157Q23.2764 1.61955 23.564 2.18404C24 3.03969 24 4.15979 24 6.4L24 13.6C24 15.8402 24 16.9603 23.564 17.816Q23.2764 18.3804 22.8284 18.8284Q22.3804 19.2764 21.816 19.564C20.9603 20 19.8402 20 17.6 20L6.4 20C4.15979 20 3.03969 20 2.18404 19.564Q1.61955 19.2764 1.17157 18.8284Q0.723594 18.3804 0.435974 17.816C0 16.9603 0 15.8402 0 13.6L0 6.4C0 4.15979 0 3.03969 0.435974 2.18404Q0.723594 1.61955 1.17157 1.17157Q1.61955 0.723594 2.18404 0.435974C3.03969 0 4.15979 0 6.4 0L12 0Z"
fill="#FFFFFF" fill-opacity="0.051" />
<path
d="M4.9427 0.0799475L0.154716 5.27144Q0.144837 5.28216 0.138427 5.29524Q0.132016 5.30833 0.129608 5.3227Q0.127199 5.33707 0.128993 5.35153Q0.130787 5.36599 0.136635 5.37934Q0.142482 5.39269 0.151896 5.40381Q0.16131 5.41493 0.173507 5.42291Q0.185705 5.43088 0.19967 5.43504Q0.213636 5.4392 0.228208 5.4392L3.06459 5.4392Q3.08448 5.4392 3.10285 5.44681Q3.12123 5.45442 3.13529 5.46848Q3.14935 5.48254 3.15696 5.50092Q3.16457 5.51929 3.16457 5.53917L3.16457 9.90003Q3.16457 9.91991 3.17218 9.93828Q3.17979 9.95666 3.19385 9.97072Q3.20791 9.98478 3.22629 9.99239Q3.24466 10 3.26455 10L6.74521 10Q6.7651 10 6.78347 9.99239Q6.80184 9.98478 6.8159 9.97072Q6.82997 9.95666 6.83758 9.93828Q6.84519 9.91991 6.84519 9.90003L6.84519 5.53917Q6.84519 5.51929 6.8528 5.50091Q6.86041 5.48254 6.87447 5.46848Q6.88853 5.45442 6.9069 5.44681Q6.92527 5.4392 6.94516 5.4392L9.77281 5.4392Q9.78736 5.4392 9.8013 5.43505Q9.81525 5.4309 9.82744 5.42295Q9.83962 5.415 9.84904 5.4039Q9.85845 5.39281 9.86431 5.37949Q9.87017 5.36617 9.87199 5.35173Q9.87382 5.3373 9.87145 5.32294Q9.86908 5.30858 9.86271 5.2955Q9.85635 5.28241 9.84652 5.27168L5.0899 0.0801888Q5.07573 0.064719 5.05653 0.0562526Q5.03734 0.0477862 5.01635 0.0477518Q4.99537 0.0477174 4.97615 0.0561208Q4.95692 0.0645242 4.9427 0.0799475Z"
fill="none" stroke-width="1.3" stroke="#E5DFD5" transform="translate(7 5)" />
</g>
</svg>
</template>

222
composables/useActions.ts Normal file
View file

@ -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,
};
}

View file

@ -0,0 +1,12 @@
import { app, window } from "@tauri-apps/api";
export function useAppControl() {
const hideApp = async (): Promise<void> => {
await app.hide();
await window.getCurrentWindow().hide();
};
return {
hideApp
};
}

View file

@ -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<HTMLElement | null>(null)
export const useSelectedResult = (groupedHistory: Ref<GroupedHistory[]>) => {
const selectedItem = computed<HistoryItem | null>(() => {
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
}
}

View file

@ -3,6 +3,7 @@ export default defineNuxtConfig({
devtools: { enabled: false }, devtools: { enabled: false },
compatibilityDate: "2024-07-04", compatibilityDate: "2024-07-04",
ssr: false, ssr: false,
app: { app: {
head: { head: {
charset: "utf-8", charset: "utf-8",
@ -10,6 +11,7 @@ export default defineNuxtConfig({
"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0", "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0",
}, },
}, },
vite: { vite: {
css: { css: {
preprocessorOptions: { preprocessorOptions: {

View file

@ -10,7 +10,7 @@
<Result v-for="(item, index) in group.items" :key="item.id" :item="item" <Result v-for="(item, index) in group.items" :key="item.id" :item="item"
:selected="isSelected(groupIndex, index)" :image-url="imageUrls[item.id]" :selected="isSelected(groupIndex, index)" :image-url="imageUrls[item.id]"
:dimensions="imageDimensions[item.id]" @select="selectItem(groupIndex, index)" @image-error="onImageError" :dimensions="imageDimensions[item.id]" @select="selectItem(groupIndex, index)" @image-error="onImageError"
@setRef="(el) => (selectedElement = el)" /> @setRef="(el: HTMLElement | null) => (selectedElement = el)" />
</div> </div>
</div> </div>
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
@ -49,9 +49,12 @@
onClick: pasteSelectedItem, onClick: pasteSelectedItem,
}" :secondary-action="{ }" :secondary-action="{
text: 'Actions', text: 'Actions',
icon: IconsK, icon: IconsKey,
input: 'K',
showModifier: true, showModifier: true,
onClick: toggleActionsMenu,
}" /> }" />
<ActionsMenu :selected-item="selectedItem" :is-visible="isActionsMenuVisible" @close="closeActionsMenu" @toggle="toggleActionsMenu" />
</main> </main>
</template> </template>
@ -59,7 +62,6 @@
import { ref, computed, onMounted, watch, nextTick, shallowRef } from "vue"; import { ref, computed, onMounted, watch, nextTick, shallowRef } from "vue";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue"; import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import "overlayscrollbars/overlayscrollbars.css"; import "overlayscrollbars/overlayscrollbars.css";
import { app, window } from "@tauri-apps/api";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { useNuxtApp } from "#app"; import { useNuxtApp } from "#app";
@ -73,22 +75,18 @@ import type {
InfoColor, InfoColor,
InfoCode, InfoCode,
} from "~/types/types"; } from "~/types/types";
import { Key, keyboard } from "wrdu-keyboard"; import IconsEnter from "~/components/Keys/Enter.vue";
import { import IconsKey from "~/components/Keys/Key.vue";
selectedGroupIndex, import ActionsMenu from "~/components/ActionsMenu.vue";
selectedItemIndex, import { useAppControl } from "~/composables/useAppControl";
selectedElement,
useSelectedResult,
} from "~/lib/selectedResult";
import IconsEnter from "~/components/Icons/Enter.vue";
import IconsK from "~/components/Icons/K.vue";
interface GroupedHistory { interface GroupedHistory {
label: string; label: string;
items: HistoryItem[]; items: HistoryItem[];
} }
const { $history } = useNuxtApp(); const { $history, $keyboard, $selectedResult } = useNuxtApp();
const { selectedGroupIndex, selectedItemIndex, selectedElement, useSelectedResult } = $selectedResult;
const CHUNK_SIZE = 50; const CHUNK_SIZE = 50;
const SCROLL_THRESHOLD = 100; const SCROLL_THRESHOLD = 100;
@ -113,9 +111,36 @@ const imageLoadError = ref<boolean>(false);
const imageLoading = ref<boolean>(false); const imageLoading = ref<boolean>(false);
const pageTitle = ref<string>(""); const pageTitle = ref<string>("");
const pageOgImage = ref<string>(""); const pageOgImage = ref<string>("");
const isActionsMenuVisible = ref<boolean>(false);
const topBar = ref<{ searchInput: HTMLInputElement | null } | null>(null); 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 => { const isSameDay = (date1: Date, date2: Date): boolean => {
return ( return (
date1.getFullYear() === date2.getFullYear() && date1.getFullYear() === date2.getFullYear() &&
@ -417,13 +442,13 @@ const getYoutubeThumbnail = (url: string): string => {
}; };
const updateHistory = async (resetScroll: boolean = false): Promise<void> => { const updateHistory = async (resetScroll: boolean = false): Promise<void> => {
const results = await $history.loadHistoryChunk(0, CHUNK_SIZE); offset = 0;
if (results.length > 0) { history.value = [];
const existingIds = new Set(history.value.map((item) => item.id));
const uniqueNewItems = results.filter((item) => !existingIds.has(item.id));
const processedNewItems = await Promise.all( const results = await $history.loadHistoryChunk(offset, CHUNK_SIZE);
uniqueNewItems.map(async (item) => { if (results.length > 0) {
const processedItems = await Promise.all(
results.map(async (item) => {
const historyItem = new HistoryItem( const historyItem = new HistoryItem(
item.source, item.source,
item.content_type, item.content_type,
@ -471,7 +496,8 @@ const updateHistory = async (resetScroll: boolean = false): Promise<void> => {
}) })
); );
history.value = [...processedNewItems, ...history.value]; history.value = processedItems;
offset = results.length;
if ( if (
resetScroll && resetScroll &&
@ -527,76 +553,40 @@ const setupEventListeners = async (): Promise<void> => {
} }
focusSearchInput(); focusSearchInput();
keyboard.clear(); $keyboard.disableContext('actionsMenu');
keyboard.prevent.down([Key.DownArrow], () => { $keyboard.disableContext('settings');
selectNext(); $keyboard.enableContext('main');
}); if (isActionsMenuVisible.value) {
$keyboard.enableContext('actionsMenu');
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;
} }
}); });
await listen("tauri://blur", () => { await listen("tauri://blur", () => {
searchInput.value?.blur(); searchInput.value?.blur();
keyboard.clear(); $keyboard.disableContext('main');
$keyboard.disableContext('actionsMenu');
}); });
keyboard.prevent.down([Key.DownArrow], () => { $keyboard.setupAppShortcuts({
selectNext(); onNavigateDown: selectNext,
}); onNavigateUp: selectPrevious,
onSelect: pasteSelectedItem,
keyboard.prevent.down([Key.UpArrow], () => { onEscape: () => {
selectPrevious(); if (isActionsMenuVisible.value) {
}); closeActionsMenu();
} else {
keyboard.prevent.down([Key.Enter], () => {
pasteSelectedItem();
});
keyboard.prevent.down([Key.Escape], () => {
hideApp(); 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;
} }
},
onToggleActions: toggleActionsMenu,
contextName: 'main',
priority: $keyboard.PRIORITY.HIGH
});
$keyboard.disableContext('settings');
$keyboard.enableContext('main');
}; };
const hideApp = async (): Promise<void> => { const { hideApp } = useAppControl();
await app.hide();
await window.getCurrentWindow().hide();
};
const focusSearchInput = (): void => { const focusSearchInput = (): void => {
nextTick(() => { nextTick(() => {
@ -628,6 +618,24 @@ onMounted(async () => {
?.viewport?.addEventListener("scroll", handleScroll); ?.viewport?.addEventListener("scroll", handleScroll);
await setupEventListeners(); 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) { } catch (error) {
console.error("Error during onMounted:", error); console.error("Error during onMounted:", error);
} }

View file

@ -45,6 +45,7 @@
<div <div
@blur="onBlur" @blur="onBlur"
@focus="onFocus" @focus="onFocus"
@keydown="onKeyDown"
class="keybind-input" class="keybind-input"
ref="keybindInput" ref="keybindInput"
tabindex="0" tabindex="0"
@ -79,20 +80,21 @@ import { platform } from "@tauri-apps/plugin-os";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { KeyValues, KeyLabels } from "../types/keys"; import { KeyValues, KeyLabels } from "../types/keys";
import { disable, enable } from "@tauri-apps/plugin-autostart"; import { disable, enable } from "@tauri-apps/plugin-autostart";
import { Key, keyboard } from "wrdu-keyboard"; import { useNuxtApp } from "#app";
import BottomBar from "../components/BottomBar.vue"; import BottomBar from "../components/BottomBar.vue";
import IconsEnter from "~/components/Icons/Enter.vue"; import IconsEnter from "~/components/Keys/Enter.vue";
const activeModifiers = reactive<Set<KeyValues>>(new Set()); const activeModifiers = reactive<Set<KeyValues>>(new Set());
const isKeybindInputFocused = ref(false); const isKeybindInputFocused = ref(false);
const keybind = ref<KeyValues[]>([]); const keybind = ref<KeyValues[]>([]);
const keybindInput = ref<HTMLElement | null>(null); const keybindInput = ref<HTMLElement | null>(null);
const lastBlurTime = ref(0); const lastBlurTime = ref(0);
const blurredByEscape = ref(false);
const os = ref(""); const os = ref("");
const router = useRouter(); const router = useRouter();
const showEmptyKeybindError = ref(false); const showEmptyKeybindError = ref(false);
const autostart = ref(false); const autostart = ref(false);
const { $settings } = useNuxtApp(); const { $settings, $keyboard } = useNuxtApp();
const modifierKeySet = new Set([ const modifierKeySet = new Set([
KeyValues.AltLeft, KeyValues.AltLeft,
@ -127,6 +129,7 @@ const onBlur = () => {
const onFocus = () => { const onFocus = () => {
isKeybindInputFocused.value = true; isKeybindInputFocused.value = true;
blurredByEscape.value = false;
activeModifiers.clear(); activeModifiers.clear();
keybind.value = []; keybind.value = [];
showEmptyKeybindError.value = false; showEmptyKeybindError.value = false;
@ -137,7 +140,10 @@ const onKeyDown = (event: KeyboardEvent) => {
if (key === KeyValues.Escape) { if (key === KeyValues.Escape) {
if (keybindInput.value) { if (keybindInput.value) {
blurredByEscape.value = true;
keybindInput.value.blur(); keybindInput.value.blur();
event.preventDefault();
event.stopPropagation();
} }
return; return;
} }
@ -174,56 +180,64 @@ const toggleAutostart = async () => {
os.value = platform(); os.value = platform();
onMounted(async () => { onMounted(async () => {
keyboard.prevent.down([Key.All], (event: KeyboardEvent) => { $keyboard.setupKeybindCapture({
onCapture: (key: string) => {
if (isKeybindInputFocused.value) { if (isKeybindInputFocused.value) {
onKeyDown(event); const keyValue = key as KeyValues;
}
});
keyboard.prevent.down([Key.Escape], () => { 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) { if (isKeybindInputFocused.value) {
keybindInput.value?.blur(); keybindInput.value?.blur();
} else { } else {
router.push("/"); 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 (os.value === "macos") {
if (!isKeybindInputFocused.value) { $keyboard.on("settings", [$keyboard.Key.LeftMeta, $keyboard.Key.Enter], () => {
saveKeybind(); saveKeybind();
} }, { priority: $keyboard.PRIORITY.HIGH });
});
break;
case "linux": $keyboard.on("settings", [$keyboard.Key.RightMeta, $keyboard.Key.Enter], () => {
case "windows":
keyboard.prevent.down([Key.LeftControl, Key.Enter], () => {
if (!isKeybindInputFocused.value) {
saveKeybind(); saveKeybind();
} }, { priority: $keyboard.PRIORITY.HIGH });
}); } else {
$keyboard.on("settings", [$keyboard.Key.LeftControl, $keyboard.Key.Enter], () => {
saveKeybind();
}, { priority: $keyboard.PRIORITY.HIGH });
keyboard.prevent.down([Key.RightControl, Key.Enter], () => { $keyboard.on("settings", [$keyboard.Key.RightControl, $keyboard.Key.Enter], () => {
if (!isKeybindInputFocused.value) {
saveKeybind(); saveKeybind();
}, { priority: $keyboard.PRIORITY.HIGH });
} }
});
break; $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"; autostart.value = (await $settings.getSetting("autostart")) === "true";
}); });
onUnmounted(() => { onUnmounted(() => {
keyboard.clear(); $keyboard.disableContext("settings");
}); });
</script> </script>

281
plugins/keyboard.ts Normal file
View file

@ -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<string>();
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,
},
},
};
});

68
plugins/selectedResult.ts Normal file
View file

@ -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<HTMLElement | null>(null);
const useSelectedResult = (groupedHistory: Ref<GroupedHistory[]>) => {
const selectedItem = computed<HistoryItem | undefined>(() => {
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
}
}
};
});

View file

@ -230,7 +230,6 @@ pub fn setup(app: &AppHandle) {
} }
} }
let _ = app_handle.emit("clipboard-content-updated", ());
let _ = app_handle.track_event( let _ = app_handle.track_event(
"clipboard_copied", "clipboard_copied",
Some( Some(

View file

@ -5,6 +5,7 @@ use rand::distr::Alphanumeric;
use sqlx::{ Row, SqlitePool }; use sqlx::{ Row, SqlitePool };
use std::fs; use std::fs;
use tauri_plugin_aptabase::EventTracker; use tauri_plugin_aptabase::EventTracker;
use tauri::Emitter;
pub async fn initialize_history(pool: &SqlitePool) -> Result<(), Box<dyn std::error::Error>> { pub async fn initialize_history(pool: &SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
let id: String = rng() let id: String = rng()
@ -71,8 +72,12 @@ pub async fn add_history_item(
Some(_) => { Some(_) => {
sqlx sqlx
::query( ::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)
.bind(&content_type) .bind(&content_type)
.execute(&*pool).await .execute(&*pool).await
@ -103,6 +108,8 @@ pub async fn add_history_item(
})) }))
); );
let _ = app_handle.emit("clipboard-content-updated", ());
Ok(()) Ok(())
} }
@ -191,6 +198,7 @@ pub async fn delete_history_item(
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let _ = app_handle.track_event("history_item_deleted", None); let _ = app_handle.track_event("history_item_deleted", None);
let _ = app_handle.emit("clipboard-content-updated", ());
Ok(()) Ok(())
} }
@ -206,6 +214,7 @@ pub async fn clear_history(
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let _ = app_handle.track_event("history_cleared", None); let _ = app_handle.track_event("history_cleared", None);
let _ = app_handle.emit("clipboard-content-updated", ());
Ok(()) Ok(())
} }

View file

@ -127,7 +127,8 @@ fn main() {
db::history::read_image, db::history::read_image,
db::settings::get_setting, db::settings::get_setting,
db::settings::save_setting, db::settings::save_setting,
utils::commands::fetch_page_meta utils::commands::fetch_page_meta,
utils::commands::get_app_info
] ]
) )
.run(tauri::generate_context!()) .run(tauri::generate_context!())

View file

@ -36,12 +36,20 @@ pub fn center_window_on_current_monitor(window: &tauri::WebviewWindow) {
} }
} }
#[tauri::command]
pub fn get_app_info() -> (String, Option<String>) { pub fn get_app_info() -> (String, Option<String>) {
println!("Getting app info"); println!("Getting app info");
let mut ctx = AppInfoContext::new(vec![]); let mut ctx = AppInfoContext::new(vec![]);
println!("Created AppInfoContext"); 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"); println!("Refreshed apps");
let result = std::panic::catch_unwind(|| {
match ctx.get_frontmost_application() { match ctx.get_frontmost_application() {
Ok(window) => { Ok(window) => {
println!("Found frontmost application: {}", window.name); println!("Found frontmost application: {}", window.name);
@ -49,12 +57,13 @@ pub fn get_app_info() -> (String, Option<String>) {
let icon = window let icon = window
.load_icon() .load_icon()
.ok() .ok()
.map(|i| { .and_then(|i| {
println!("Loading icon for {}", name); println!("Loading icon for {}", name);
let png = i.to_png().unwrap(); i.to_png().ok().map(|png| {
let encoded = STANDARD.encode(png.get_bytes()); let encoded = STANDARD.encode(png.get_bytes());
println!("Icon encoded successfully"); println!("Icon encoded successfully");
encoded encoded
})
}); });
println!("Returning app info: {} with icon: {}", name, icon.is_some()); println!("Returning app info: {} with icon: {}", name, icon.is_some());
(name, icon) (name, icon)
@ -64,6 +73,15 @@ pub fn get_app_info() -> (String, Option<String>) {
("System".to_string(), None) ("System".to_string(), None)
} }
} }
});
match result {
Ok(info) => info,
Err(_) => {
println!("Panic occurred while getting app info");
("System".to_string(), None)
}
}
} }
fn _process_icon_to_base64(path: &str) -> Result<String, Box<dyn std::error::Error>> { fn _process_icon_to_base64(path: &str) -> Result<String, Box<dyn std::error::Error>> {

View file

@ -138,7 +138,6 @@ main {
justify-content: space-between; justify-content: space-between;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid $divider; border-bottom: 1px solid $divider;
line-height: 1;
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
@ -146,7 +145,7 @@ main {
} }
&:first-child { &:first-child {
padding-top: 22px; padding-top: 14px;
} }
p { p {