This commit is contained in:
0PandaDEV 2024-08-27 20:47:43 +10:00
commit a5c02c4aa2
No known key found for this signature in database
21 changed files with 842 additions and 563 deletions

View file

@ -54,6 +54,7 @@ Qopy is a fixed clipboard manager designed as a simple alternative to the standa
- [ ] Metadata for copied items https://github.com/0PandaDEV/Qopy/issues/5 - [ ] Metadata for copied items https://github.com/0PandaDEV/Qopy/issues/5
- [ ] Code highlighting https://github.com/0PandaDEV/Qopy/issues/7 - [ ] Code highlighting https://github.com/0PandaDEV/Qopy/issues/7
- [ ] Streamshare integration https://github.com/0PandaDEV/Qopy/issues/4 - [ ] Streamshare integration https://github.com/0PandaDEV/Qopy/issues/4
- [ ] Cross-device clipboard sharing https://github.com/0PandaDEV/Qopy/issues/8
<sup>If you have ideas for features to include, please write a feature request [here](https://github.com/0pandadev/Qopy/issues).</sup> <sup>If you have ideas for features to include, please write a feature request [here](https://github.com/0pandadev/Qopy/issues).</sup>

481
app.vue
View file

@ -1,434 +1,75 @@
<template> <template>
<div class="bg" @keydown.down.prevent="selectNext" @keydown.up.prevent="selectPrevious"
@keydown.enter.prevent="pasteSelectedItem" @keydown.esc="hideApp" tabindex="0">
<input ref="searchInput" v-model="searchQuery" @input="searchHistory" autocorrect="off" autocapitalize="off"
spellcheck="false" class="search" type="text" placeholder="Type to filter entries...">
<div class="bottom-bar">
<div class="left">
<img class="logo" width="18px" src="/logo.png" alt="">
<p>Qopy</p>
</div>
<div class="right">
<div class="paste" @click="pasteSelectedItem">
<p>Paste</p>
<img src="/enter.svg" alt="">
</div>
<div class="divider"></div>
<div class="actions">
<p>Actions</p>
<div> <div>
<img v-if="os === 'windows' || os === 'linux'" src="/ctrl.svg" alt=""> <NuxtPage />
<img v-if="os === 'macos'" src="/cmd.svg" alt="">
<img src="/k.svg" alt="">
</div>
</div>
</div>
</div>
<OverlayScrollbarsComponent class="results" ref="resultsContainer"
:options="{ scrollbars: { autoHide: 'scroll' } }">
<template v-for="(group, groupIndex) in groupedHistory" :key="groupIndex">
<div class="time-separator">{{ group.label }}</div>
<div v-for="(item, index) in group.items" :key="item.id"
:class="['result clothoid-corner', { 'selected': isSelected(groupIndex, index) }]"
@click="selectItem(groupIndex, index)"
:ref="el => { if (isSelected(groupIndex, index)) selectedElement = el as HTMLElement }">
<template v-if="item.content_type === 'image'">
<img :src="getComputedImageUrl(item)" alt="Image" class="image" @error="onImageError">
<IconsImage v-show="imageLoadError" class="icon" />
</template>
<img v-else-if="hasFavicon(item.favicon ?? '')" :src="getFaviconFromDb(item.favicon ?? '')" alt="Favicon" class="favicon">
<IconsFile class="icon" v-else-if="item.content_type === 'files'" />
<IconsText class="icon" v-else-if="item.content_type === 'text'" />
<IconsCode class="icon" v-else-if="item.content_type === 'code'" />
<span v-if="item.content_type === 'image'">Image ({{ item.dimensions || 'Loading...' }})</span>
<span v-else>{{ truncateContent(item.content) }}</span>
</div>
</template>
</OverlayScrollbarsComponent>
<div class="content" v-if="selectedItem?.content_type === 'image'">
<img :src="getComputedImageUrl(selectedItem)" alt="Image" class="image">
</div>
<OverlayScrollbarsComponent v-else class="content">
<img v-if="selectedItem?.content && isYoutubeWatchUrl(selectedItem.content)" :src="getYoutubeThumbnail(selectedItem.content)"
alt="YouTube Thumbnail" class="full-image">
<span v-else>{{ selectedItem?.content || '' }}</span>
</OverlayScrollbarsComponent>
<Noise />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick, shallowRef } from 'vue'; import { listen } from '@tauri-apps/api/event'
import Database from '@tauri-apps/plugin-sql';
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import 'overlayscrollbars/overlayscrollbars.css';
import { app, window } from '@tauri-apps/api'; import { app, window } from '@tauri-apps/api';
import { platform } from '@tauri-apps/plugin-os'; import { onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core';
import { enable, isEnabled } from "@tauri-apps/plugin-autostart";
import { listen } from '@tauri-apps/api/event';
import { readFile } from '@tauri-apps/plugin-fs';
interface HistoryItem {
id: number;
content: string;
content_type: string;
timestamp: string;
favicon?: string;
dimensions?: string;
}
interface GroupedHistory {
label: string;
items: HistoryItem[];
}
const db: Ref<Database | null> = ref(null);
const history: Ref<HistoryItem[]> = ref([]);
const chunkSize: number = 50;
let offset: number = 0;
let isLoading: boolean = false;
const resultsContainer: Ref<InstanceType<typeof OverlayScrollbarsComponent> | null> = ref(null);
const searchQuery: Ref<string> = ref('');
const selectedGroupIndex: Ref<number> = ref(0);
const selectedItemIndex: Ref<number> = ref(0);
const selectedElement: Ref<HTMLElement | null> = ref(null);
const searchInput: Ref<HTMLInputElement | null> = ref(null);
const os: Ref<string> = ref('');
const groupedHistory: ComputedRef<GroupedHistory[]> = computed(() => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const getWeekNumber = (d: Date): number => {
d = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((Number(d) - Number(yearStart)) / 86400000 + 1) / 7);
};
const thisWeek = getWeekNumber(now);
const thisYear = now.getFullYear();
const groups: GroupedHistory[] = [
{ label: 'Today', items: [] },
{ label: 'Yesterday', items: [] },
{ label: 'This Week', items: [] },
{ label: 'Last Week', items: [] },
{ label: 'This Year', items: [] },
{ label: 'Last Year', items: [] },
];
const filteredItems = searchQuery.value
? history.value.filter(item => item.content.toLowerCase().includes(searchQuery.value.toLowerCase()))
: history.value;
filteredItems.forEach(item => {
const itemDate = new Date(item.timestamp);
const itemWeek = getWeekNumber(itemDate);
const itemYear = itemDate.getFullYear();
if (itemDate.toDateString() === today.toDateString()) {
groups[0].items.push(item);
} else if (itemDate.toDateString() === new Date(today.getTime() - 86400000).toDateString()) {
groups[1].items.push(item);
} else if (itemYear === thisYear && itemWeek === thisWeek) {
groups[2].items.push(item);
} else if (itemYear === thisYear && itemWeek === thisWeek - 1) {
groups[3].items.push(item);
} else if (itemYear === thisYear) {
groups[4].items.push(item);
} else {
groups[5].items.push(item);
}
});
return groups.filter(group => group.items.length > 0);
});
const selectedItem: ComputedRef<HistoryItem | null> = computed(() => {
const group = groupedHistory.value[selectedGroupIndex.value];
return group ? group.items[selectedItemIndex.value] : null;
});
const isSelected = (groupIndex: number, itemIndex: number): boolean => {
return selectedGroupIndex.value === groupIndex && selectedItemIndex.value === itemIndex;
};
const searchHistory = async (): Promise<void> => {
if (!db.value) return;
history.value = [];
offset = 0;
const query = `%${searchQuery.value}%`;
const results = await db.value.select<HistoryItem[]>(
'SELECT * FROM history WHERE content LIKE ? ORDER BY timestamp DESC LIMIT ?',
[query, chunkSize]
);
history.value = await Promise.all(results.map(async item => {
if (item.content_type === 'image') {
const dimensions = await getImageDimensions(item.content);
return { ...item, dimensions };
}
return item;
}));
};
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;
}
scrollToSelectedItem();
};
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;
}
scrollToSelectedItem();
};
const selectItem = (groupIndex: number, itemIndex: number): void => {
selectedGroupIndex.value = groupIndex;
selectedItemIndex.value = itemIndex;
scrollToSelectedItem();
};
const pasteSelectedItem = async (): Promise<void> => {
if (!selectedItem.value) return;
let content = selectedItem.value.content;
let contentType: String = selectedItem.value.content_type;
if (contentType === 'image') {
try {
content = readFile(content).toString();
} catch (error) {
console.error('Error reading image file:', error);
return;
}
}
await hideApp();
await invoke("write_and_paste", {
content,
contentType
});
};
const truncateContent = (content: string): string => {
const maxWidth = 284;
const charWidth = 9;
const maxChars = Math.floor(maxWidth / charWidth);
return content.length > maxChars ? content.slice(0, maxChars - 3) + '...' : content;
};
const hasFavicon = (str: string): boolean => {
return str.trim() !== '';
};
const isYoutubeWatchUrl = (url: string): boolean => {
return /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/watch\?v=[\w-]+/.test(url) || /^(https?:\/\/)?(www\.)?youtu\.be\/[\w-]+/.test(url);
};
const getYoutubeThumbnail = (url: string): string => {
let videoId;
if (url.includes('youtu.be')) {
videoId = url.split('youtu.be/')[1];
} else {
videoId = url.match(/[?&]v=([^&]+)/)?.[1];
}
return `https://img.youtube.com/vi/${videoId}/0.jpg`;
};
const getFaviconFromDb = (favicon: string): string => {
return `data:image/png;base64,${favicon}`;
};
const getImageDimensions = (path: string): Promise<string> => {
return new Promise(async (resolve) => {
const img = new Image();
img.onload = () => resolve(`${img.width}x${img.height}`);
img.onerror = () => resolve('0x0');
if (path.includes('AppData\\Roaming\\net.pandadev.qopy\\images\\')) {
const filename = path.split('\\').pop();
try {
const imageData = await invoke<Uint8Array>("read_image", { filename: filename });
const blob = new Blob([imageData], { type: 'image/png' });
img.src = URL.createObjectURL(blob);
} catch (error) {
console.error('Error reading image file:', error);
resolve('0x0');
}
} else {
img.src = `data:image/png;base64,${path}`;
}
});
};
const imageUrls: Ref<Record<number, string>> = shallowRef({});
const getComputedImageUrl = (item: HistoryItem): string => {
if (!imageUrls.value[item.id]) {
imageUrls.value[item.id] = '';
getImageUrl(item.content).then(url => {
imageUrls.value = { ...imageUrls.value, [item.id]: url };
});
}
return imageUrls.value[item.id] || '';
};
const getImageUrl = async (path: string): Promise<string> => {
if (path.includes('AppData\\Roaming\\net.pandadev.qopy\\images\\')) {
const filename = path.split('\\').pop();
try {
const imageData = await invoke<Uint8Array>("read_image", { filename: filename });
const blob = new Blob([imageData], { type: 'image/png' });
return URL.createObjectURL(blob);
} catch (error) {
console.error('Error reading image file:', error);
return '';
}
} else {
return `data:image/png;base64,${path}`;
}
};
const loadHistoryChunk = async (): Promise<void> => {
if (!db.value || isLoading) return;
isLoading = true;
let results: HistoryItem[];
if (searchQuery.value) {
const query = `%${searchQuery.value}%`;
results = await db.value.select(
'SELECT * FROM history WHERE content LIKE ? ORDER BY timestamp DESC LIMIT ? OFFSET ?',
[query, chunkSize, offset]
);
} else {
results = await db.value.select(
'SELECT * FROM history ORDER BY timestamp DESC LIMIT ? OFFSET ?',
[chunkSize, offset]
);
}
if (results.length === 0) {
isLoading = false;
return;
}
const processedChunk = await Promise.all(results.map(async item => {
if (item.content_type === 'image') {
const dimensions = await getImageDimensions(item.content);
return { ...item, dimensions };
}
return item;
}));
history.value = [...history.value, ...processedChunk];
offset += chunkSize;
isLoading = false;
};
const handleScroll = (): void => {
if (!resultsContainer.value) return;
const { viewport } = resultsContainer.value?.osInstance().elements() ?? {};
const { scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = viewport ?? {};
if (scrollHeight - scrollTop - clientHeight < 100) {
loadHistoryChunk();
}
};
const hideApp = async (): Promise<void> => {
await app.hide();
await window.getCurrentWindow().hide();
};
const focusSearchInput = (): void => {
nextTick(() => {
searchInput.value?.focus();
});
};
const scrollToSelectedItem = (): void => {
nextTick(() => {
if (selectedElement.value && resultsContainer.value) {
const osInstance = resultsContainer.value.osInstance();
const viewport = osInstance?.elements().viewport;
if (!viewport) return;
const viewportRect = viewport.getBoundingClientRect();
const elementRect = selectedElement.value.getBoundingClientRect();
const isAbove = elementRect.top < viewportRect.top;
const isBelow = elementRect.bottom > viewportRect.bottom - 8;
if (isAbove || isBelow) {
let scrollOffset;
if (isAbove && selectedItemIndex.value === 0 && selectedGroupIndex.value === 0) {
scrollOffset = elementRect.top - viewportRect.top - 36;
} else if (isAbove) {
scrollOffset = elementRect.top - viewportRect.top - 8;
} else {
scrollOffset = elementRect.bottom - viewportRect.bottom + 9;
}
viewport.scrollBy({
top: scrollOffset,
behavior: 'smooth'
});
}
}
});
};
watch([selectedGroupIndex, selectedItemIndex], scrollToSelectedItem);
watch(searchQuery, () => {
searchHistory();
});
onMounted(async () => { onMounted(async () => {
db.value = await Database.load('sqlite:data.db'); await listen('change_keybind', async () => {
await loadHistoryChunk(); await navigateTo('/keybind')
await app.show();
if (resultsContainer.value) { await window.getCurrentWindow().show();
resultsContainer.value.osInstance().elements().viewport.addEventListener('scroll', handleScroll); })
}
await listen('tauri://focus', async () => {
history.value = [];
offset = 0;
await loadHistoryChunk();
focusSearchInput();
});
await listen('tauri://blur', () => {
if (searchInput.value) {
searchInput.value.blur();
}
});
if (!await isEnabled()) {
await enable()
}
os.value = await platform();
});
await listen('main_route', async () => {
await navigateTo('/')
})
})
</script> </script>
<style lang="scss"> <style lang="scss">
@import '~/assets/css/style.scss'; @font-face {
font-family: SFRoundedRegular;
font-display: swap;
src: url("~/assets/fonts/SFRoundedRegular.otf") format("opentype");
}
@font-face {
font-family: SFRoundedMedium;
font-display: swap;
src: url("~/assets/fonts/SFRoundedMedium.otf") format("opentype");
}
@font-face {
font-family: SFRoundedSemiBold;
font-display: swap;
src: url("~/assets/fonts/SFRoundedSemiBold.otf") format("opentype");
}
@font-face {
font-family: CommitMono;
font-display: swap;
src: url("~/assets/fonts/CommitMono.woff2") format("woff2");
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
color: #E5DFD5;
text-decoration: none;
font-family: SFRoundedRegular;
scroll-behavior: smooth;
scrollbar-width: thin;
user-select: none;
--os-handle-bg: #ADA9A1;
--os-handle-bg-hover: #78756F;
--os-handle-bg-active: #78756F;
}
html,
body,
#__nuxt {
background-color: transparent;
}
.os-scrollbar-horizontal {
display: none;
}
</style> </style>

View file

@ -6,56 +6,6 @@ $text: #E5DFD5;
$text2: #ADA9A1; $text2: #ADA9A1;
$mutedtext: #78756F; $mutedtext: #78756F;
@font-face {
font-family: SFRoundedRegular;
font-display: swap;
src: url("~/assets/fonts/SFRoundedRegular.otf") format("woff2");
}
@font-face {
font-family: SFRoundedMedium;
font-display: swap;
src: url("~/assets/fonts/SFRoundedMedium.otf") format("woff2");
}
@font-face {
font-family: SFRoundedSemiBold;
font-display: swap;
src: url("~/assets/fonts/SFRoundedSemiBold.otf") format("woff2");
}
@font-face {
font-family: SFMonoRegular;
font-display: swap;
src: url("~/assets/fonts/SFMonoRegular.otf") format("woff2");
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
color: $text;
text-decoration: none;
font-family: SFRoundedRegular;
scroll-behavior: smooth;
scrollbar-width: thin;
user-select: none;
--os-handle-bg: #ADA9A1;
--os-handle-bg-hover: #78756F;
--os-handle-bg-active: #78756F;
}
html,
body,
#__nuxt {
background-color: transparent;
}
.os-scrollbar-horizontal {
display: none;
}
.bg { .bg {
width: 750px; width: 750px;
height: 474px; height: 474px;
@ -88,7 +38,7 @@ body,
width: 284px; width: 284px;
top: 53px; top: 53px;
left: 0; left: 0;
height: calc(100vh - 96px); height: calc(100vh - 95px);
border-right: 1px solid $divider; border-right: 1px solid $divider;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -105,7 +55,7 @@ body,
padding: 10px; padding: 10px;
padding-inline: 10px; padding-inline: 10px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
gap: 16px; gap: 10px;
overflow: hidden; overflow: hidden;
text-overflow: clip; text-overflow: clip;
white-space: nowrap; white-space: nowrap;
@ -129,12 +79,13 @@ body,
} }
.favicon { .favicon {
width: 20px; width: 18px;
height: 18px;
} }
.image { .image {
width: 20px; width: 18px;
height: 20px; height: 18px;
} }
.icon { .icon {
@ -149,7 +100,7 @@ body,
left: 284px; left: 284px;
padding: 8px; padding: 8px;
height: calc(100vh - 96px); height: calc(100vh - 96px);
font-family: SFMonoRegular !important; font-family: CommitMono !important;
font-size: 14px; font-size: 14px;
letter-spacing: 1; letter-spacing: 1;
border-radius: 10px; border-radius: 10px;
@ -159,7 +110,7 @@ body,
div { div {
border-radius: 10px; border-radius: 10px;
font-family: SFMonoRegular !important; font-family: CommitMono !important;
} }
.full-image { .full-image {
@ -178,12 +129,12 @@ body,
} }
.bottom-bar { .bottom-bar {
height: 41px; height: 40px;
width: calc(100vw - 3px); width: calc(100vw - 2px);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
background-color: rgba(46, 45, 43, 0.8); background-color: hsla(40, 3%, 16%, 0.8);
position: fixed; position: fixed;
bottom: 2px; bottom: 1px;
left: 1px; left: 1px;
z-index: 100; z-index: 100;
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
@ -191,6 +142,8 @@ body,
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
padding-inline: 12px; padding-inline: 12px;
padding-right: 6px;
padding-top: 1px;
align-items: center; align-items: center;
font-size: 14px; font-size: 14px;
border-top: 1px solid $divider; border-top: 1px solid $divider;
@ -240,9 +193,10 @@ body,
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
border-radius: 6px; border-radius: 7px;
background-color: transparent; background-color: transparent;
transition: all .2s; transition: all .2s;
cursor: pointer;
} }
.paste:hover, .paste:hover,

66
assets/css/keybind.scss Normal file
View file

@ -0,0 +1,66 @@
$primary: #2E2D2B;
$accent: #FEB453;
$divider: #ffffff0d;
$text: #E5DFD5;
$text2: #ADA9A1;
$mutedtext: #78756F;
.bg {
width: 750px;
height: 474px;
background-color: $primary;
border: 1px solid $divider;
border-radius: 12px;
z-index: -1;
position: fixed;
outline: none;
}
.keybind-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 20px;
}
h2 {
margin-bottom: 20px;
}
.keybind-input {
width: 300px;
height: 50px;
border: 2px solid $accent;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
cursor: pointer;
margin-bottom: 20px;
background-color: rgba($accent, 0.1);
user-select: none;
}
.keybind-input:focus {
outline: none;
box-shadow: 0 0 0 2px rgba($accent, 0.5);
}
button {
padding: 10px 20px;
background-color: $accent;
color: $primary;
border: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

Binary file not shown.

Binary file not shown.

View file

@ -1,3 +1,11 @@
<template> <template>
<img src="/Code.svg" alt=""> <svg width="15px" height="18px" viewBox="0 0 15 18" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg">
<g id="Code" fill-opacity="1">
<path d="M15 0L15 0L15 18L0 18L0 0L15 0Z" id="Code" fill="none" stroke="none" />
<path
d="M3.75 16.0714L11.25 16.0714C12.2855 16.0714 13.125 15.208 13.125 14.1429L13.125 8.02672C13.1248 7.5147 12.9269 7.02399 12.5748 6.66239L8.52375 2.493C8.17225 2.13169 7.69568 1.92868 7.19875 1.92857L3.75 1.92857C2.71447 1.92857 1.875 2.79202 1.875 3.85714L1.875 14.1429C1.875 15.208 2.71447 16.0714 3.75 16.0714M15 8.02672C15.0003 7.00424 14.6053 6.02271 13.9018 5.29904L9.85 1.13143C9.1465 0.406921 8.19178 -0.000123228 7.19625 0L3.75 0C1.67893 0 0 1.7269 0 3.85714L0 14.1429C2.98023e-07 16.2731 1.67893 18 3.75 18L11.25 18C13.3211 18 15 16.2731 15 14.1429L15 8.02672ZM8.4 12.2529C8.03443 11.8764 8.03443 11.2665 8.4 10.89L9.6125 9.64286L8.4 8.39571C8.0558 8.01577 8.06595 7.4237 8.42297 7.05648C8.77999 6.68927 9.35561 6.67882 9.725 7.03286L11.6 8.96143C11.9656 9.33791 11.9656 9.94781 11.6 10.3243L9.725 12.2529C9.35898 12.6289 8.76602 12.6289 8.4 12.2529M6.6 8.39571C6.9442 8.01577 6.93404 7.4237 6.57703 7.05649C6.22001 6.68927 5.64439 6.67882 5.275 7.03286L3.4 8.96143C3.03443 9.33791 3.03443 9.94781 3.4 10.3243L5.275 12.2529C5.50871 12.5108 5.86069 12.617 6.19286 12.5298C6.52502 12.4425 6.7844 12.1757 6.86923 11.8341C6.95406 11.4924 6.85081 11.1304 6.6 10.89L5.3875 9.64286L6.6 8.39571Z"
id="Shape" fill="#E5DFD5" fill-rule="evenodd" stroke="none" />
</g>
</svg>
</template> </template>

View file

@ -1,3 +1,11 @@
<template> <template>
<img src="/File.svg" alt=""> <svg width="15px" height="18px" viewBox="0 0 15 18" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg">
<g id="File" fill-opacity="1">
<path d="M15 0L15 0L15 18L0 18L0 0L15 0Z" id="File" fill="none" stroke="none" />
<path
d="M11.25 16.0714L3.75 16.0714C2.71447 16.0714 1.875 15.208 1.875 14.1429L1.875 3.85714C1.875 2.79202 2.71447 1.92857 3.75 1.92857L6.25 1.92857L6.25 5.14286C6.25 7.2731 7.92893 9 10 9L13.125 9L13.125 14.1429C13.125 15.208 12.2855 16.0714 11.25 16.0714M12.8788 7.07143C12.7961 6.92188 12.6944 6.78437 12.5763 6.66257L8.5225 2.493C8.40408 2.37151 8.2704 2.26687 8.125 2.18186L8.125 5.14286C8.125 6.20798 8.96447 7.07143 10 7.07143L12.8788 7.07143ZM13.9013 5.29843C14.6049 6.02193 15.0001 7.00338 15 8.02672L15 14.1429C15 16.2731 13.3211 18 11.25 18L3.75 18C1.67893 18 0 16.2731 0 14.1429L0 3.85714C-5.96046e-07 1.7269 1.67893 0 3.75 0L7.19625 0C8.19116 -0.000122309 9.14535 0.406423 9.84875 1.13014L13.9013 5.29843Z"
id="Shape" fill="#E5DFD5" fill-rule="evenodd" stroke="none" />
</g>
</svg>
</template> </template>

View file

@ -1,3 +1,11 @@
<template> <template>
<img src="/Image.svg" alt=""> <svg width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg">
<g id="Image" fill-opacity="1">
<path d="M18 0L18 0L18 18L0 18L0 0L18 0Z" id="Image" fill="none" stroke="none" />
<path
d="M13.8462 2.07692L4.15385 2.07692C3.00679 2.07692 2.07692 3.00679 2.07692 4.15385L2.07692 11.1143L3.40892 10.1451C4.26934 9.51991 5.43685 9.5289 6.28754 10.1672L7.57246 11.1309L10.8512 8.32016C11.7843 7.52111 13.1676 7.54669 14.0705 8.37969L15.9231 10.0897L15.9231 4.15385C15.9231 3.00679 14.9932 2.07692 13.8462 2.07692M18 12.4588L18 4.15385C18 1.85974 16.1403 0 13.8462 0L4.15385 0C1.85974 0 0 1.85974 0 4.15385L0 13.8462C3.30118e-07 16.1403 1.85974 18 4.15385 18L13.8462 18C16.1403 18 18 16.1403 18 13.8462L18 12.4588ZM15.9231 12.9157L12.6623 9.90554C12.5333 9.78671 12.3358 9.78314 12.2026 9.89723L8.29108 13.2508L7.65831 13.7935L6.99231 13.2937L5.04 11.8302C4.91867 11.7398 4.75269 11.7386 4.63015 11.8274L2.07692 13.6814L2.07692 13.8462C2.07692 14.9932 3.00679 15.9231 4.15385 15.9231L13.8462 15.9231C14.9932 15.9231 15.9231 14.9932 15.9231 13.8462L15.9231 12.9157ZM8.30769 6.23077C8.30769 7.37782 7.37782 8.30769 6.23077 8.30769C5.08372 8.30769 4.15385 7.37782 4.15385 6.23077C4.15385 5.08372 5.08372 4.15385 6.23077 4.15385C7.37782 4.15385 8.30769 5.08372 8.30769 6.23077"
id="Shape" fill="#E5DFD5" fill-rule="evenodd" stroke="none" />
</g>
</svg>
</template> </template>

View file

@ -1,3 +1,11 @@
<template> <template>
<img src="/Text.svg" alt=""> <svg width="15px" height="18px" viewBox="0 0 15 18" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg">
<g id="Text" fill-opacity="1">
<path d="M15 0L15 0L15 18L0 18L0 0L15 0Z" id="Text" fill="none" stroke="none" />
<path
d="M3.75 16.0714L11.25 16.0714C12.2855 16.0714 13.125 15.208 13.125 14.1429L13.125 8.02672C13.1248 7.5147 12.9269 7.02399 12.5748 6.66239L8.52375 2.493C8.17225 2.13169 7.69568 1.92868 7.19875 1.92857L3.75 1.92857C2.71447 1.92857 1.875 2.79202 1.875 3.85714L1.875 14.1429C1.875 15.208 2.71447 16.0714 3.75 16.0714M15 8.02672C15.0003 7.00424 14.6053 6.02271 13.9018 5.29904L9.85 1.13143C9.1465 0.406921 8.19178 -0.000123228 7.19625 0L3.75 0C1.67893 0 0 1.7269 0 3.85714L0 14.1429C2.98023e-07 16.2731 1.67893 18 3.75 18L11.25 18C13.3211 18 15 16.2731 15 14.1429L15 8.02672ZM3.75 9.32143C3.75 8.78887 4.16973 8.35714 4.6875 8.35714L10.3125 8.35714C10.8303 8.35714 11.25 8.78887 11.25 9.32143C11.25 9.85399 10.8303 10.2857 10.3125 10.2857L4.6875 10.2857C4.16973 10.2857 3.75 9.85399 3.75 9.32143M4.6875 12.2143C4.35256 12.2143 4.04307 12.3981 3.8756 12.6964C3.70813 12.9948 3.70813 13.3624 3.8756 13.6607C4.04307 13.9591 4.35256 14.1429 4.6875 14.1429L7.8125 14.1429C8.14744 14.1429 8.45693 13.9591 8.6244 13.6607C8.79187 13.3624 8.79187 12.9948 8.6244 12.6964C8.45693 12.3981 8.14744 12.2143 7.8125 12.2143L4.6875 12.2143Z"
id="Shape" fill="#E5DFD5" fill-rule="evenodd" stroke="none" />
</g>
</svg>
</template> </template>

459
pages/index.vue Normal file
View file

@ -0,0 +1,459 @@
<template>
<div class="bg" @keydown.down.prevent="selectNext" @keydown.up.prevent="selectPrevious"
@keydown.enter.prevent="pasteSelectedItem" @keydown.esc="hideApp" tabindex="0">
<input ref="searchInput" v-model="searchQuery" @input="searchHistory" autocorrect="off" autocapitalize="off"
spellcheck="false" class="search" type="text" placeholder="Type to filter entries...">
<div class="bottom-bar">
<div class="left">
<img class="logo" width="18px" src="../public/logo.png" alt="">
<p>Qopy</p>
</div>
<div class="right">
<div class="paste" @click="pasteSelectedItem">
<p>Paste</p>
<img src="../public/enter.svg" alt="">
</div>
<div class="divider"></div>
<div class="actions">
<p>Actions</p>
<div>
<img v-if="os === 'windows' || os === 'linux'" src="../public/ctrl.svg" alt="">
<img v-if="os === 'macos'" src="../public/cmd.svg" alt="">
<img src="../public/k.svg" alt="">
</div>
</div>
</div>
</div>
<OverlayScrollbarsComponent class="results" ref="resultsContainer"
:options="{ scrollbars: { autoHide: 'scroll' } }">
<template v-for="(group, groupIndex) in groupedHistory" :key="groupIndex">
<div class="time-separator">{{ group.label }}</div>
<div v-for="(item, index) in group.items" :key="item.id"
:class="['result clothoid-corner', { 'selected': isSelected(groupIndex, index) }]"
@click="selectItem(groupIndex, index)"
:ref="el => { if (isSelected(groupIndex, index)) selectedElement = el as HTMLElement }">
<template v-if="item.content_type === 'image'">
<img v-if="!imageLoading && !imageLoadError" :src="getComputedImageUrl(item)" alt="Image" class="image"
@error="onImageError">
<IconsImage v-if="imageLoading || imageLoadError" class="icon" />
</template>
<img v-else-if="hasFavicon(item.favicon ?? '')" :src="getFaviconFromDb(item.favicon ?? '')" alt="Favicon"
class="favicon">
<IconsFile class="icon" v-else-if="item.content_type === 'files'" />
<IconsText class="icon" v-else-if="item.content_type === 'text'" />
<IconsCode class="icon" v-else-if="item.content_type === 'code'" />
<span v-if="item.content_type === 'image'">Image ({{ item.dimensions || 'Loading...' }})</span>
<span v-else>{{ truncateContent(item.content) }}</span>
</div>
</template>
</OverlayScrollbarsComponent>
<div class="content" v-if="selectedItem?.content_type === 'image'">
<img :src="getComputedImageUrl(selectedItem)" alt="Image" class="image">
</div>
<OverlayScrollbarsComponent v-else class="content">
<img v-if="selectedItem?.content && isYoutubeWatchUrl(selectedItem.content)"
:src="getYoutubeThumbnail(selectedItem.content)" alt="YouTube Thumbnail" class="full-image">
<span v-else>{{ selectedItem?.content || '' }}</span>
</OverlayScrollbarsComponent>
<Noise />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick, shallowRef } from 'vue';
import Database from '@tauri-apps/plugin-sql';
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import 'overlayscrollbars/overlayscrollbars.css';
import { app, window } from '@tauri-apps/api';
import { platform } from '@tauri-apps/plugin-os';
import { invoke } from '@tauri-apps/api/core';
import { enable, isEnabled } from "@tauri-apps/plugin-autostart";
import { listen } from '@tauri-apps/api/event';
import { readFile } from '@tauri-apps/plugin-fs';
interface HistoryItem {
id: number;
content: string;
content_type: string;
timestamp: string;
favicon?: string;
dimensions?: string;
}
interface GroupedHistory {
label: string;
items: HistoryItem[];
}
const db: Ref<Database | null> = ref(null);
const history: Ref<HistoryItem[]> = ref([]);
const chunkSize: number = 50;
let offset: number = 0;
let isLoading: boolean = false;
const resultsContainer: Ref<InstanceType<typeof OverlayScrollbarsComponent> | null> = ref(null);
const searchQuery: Ref<string> = ref('');
const selectedGroupIndex: Ref<number> = ref(0);
const selectedItemIndex: Ref<number> = ref(0);
const selectedElement: Ref<HTMLElement | null> = ref(null);
const searchInput: Ref<HTMLInputElement | null> = ref(null);
const os: Ref<string> = ref('');
const imageLoadError = ref(false);
const imageLoading = ref(true);
const imageUrls: Ref<Record<number, string>> = shallowRef({});
const groupedHistory: ComputedRef<GroupedHistory[]> = computed(() => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const getWeekNumber = (d: Date): number => {
d = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((Number(d) - Number(yearStart)) / 86400000 + 1) / 7);
};
const thisWeek = getWeekNumber(now);
const thisYear = now.getFullYear();
const groups: GroupedHistory[] = [
{ label: 'Today', items: [] },
{ label: 'Yesterday', items: [] },
{ label: 'This Week', items: [] },
{ label: 'Last Week', items: [] },
{ label: 'This Year', items: [] },
{ label: 'Last Year', items: [] },
];
const filteredItems = searchQuery.value
? history.value.filter(item => item.content.toLowerCase().includes(searchQuery.value.toLowerCase()))
: history.value;
filteredItems.forEach(item => {
const itemDate = new Date(item.timestamp);
const itemWeek = getWeekNumber(itemDate);
const itemYear = itemDate.getFullYear();
if (itemDate.toDateString() === today.toDateString()) {
groups[0].items.push(item);
} else if (itemDate.toDateString() === new Date(today.getTime() - 86400000).toDateString()) {
groups[1].items.push(item);
} else if (itemYear === thisYear && itemWeek === thisWeek) {
groups[2].items.push(item);
} else if (itemYear === thisYear && itemWeek === thisWeek - 1) {
groups[3].items.push(item);
} else if (itemYear === thisYear) {
groups[4].items.push(item);
} else {
groups[5].items.push(item);
}
});
return groups.filter(group => group.items.length > 0);
});
const selectedItem: ComputedRef<HistoryItem | null> = computed(() => {
const group = groupedHistory.value[selectedGroupIndex.value];
return group ? group.items[selectedItemIndex.value] : null;
});
const isSelected = (groupIndex: number, itemIndex: number): boolean => {
return selectedGroupIndex.value === groupIndex && selectedItemIndex.value === itemIndex;
};
const searchHistory = async (): Promise<void> => {
if (!db.value) return;
history.value = [];
offset = 0;
const query = `%${searchQuery.value}%`;
const results = await db.value.select<HistoryItem[]>(
'SELECT * FROM history WHERE content LIKE ? ORDER BY timestamp DESC LIMIT ?',
[query, chunkSize]
);
history.value = await Promise.all(results.map(async item => {
if (item.content_type === 'image') {
const dimensions = await getImageDimensions(item.content);
return { ...item, dimensions };
}
return item;
}));
};
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;
}
scrollToSelectedItem();
};
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;
}
scrollToSelectedItem();
};
const selectItem = (groupIndex: number, itemIndex: number): void => {
selectedGroupIndex.value = groupIndex;
selectedItemIndex.value = itemIndex;
scrollToSelectedItem();
};
const pasteSelectedItem = async (): Promise<void> => {
if (!selectedItem.value) return;
let content = selectedItem.value.content;
let contentType: String = selectedItem.value.content_type;
if (contentType === 'image') {
try {
content = readFile(content).toString();
} catch (error) {
console.error('Error reading image file:', error);
return;
}
}
await hideApp();
await invoke("write_and_paste", {
content,
contentType
});
};
const truncateContent = (content: string): string => {
const maxWidth = 284;
const charWidth = 9;
const maxChars = Math.floor(maxWidth / charWidth);
return content.length > maxChars ? content.slice(0, maxChars - 3) + '...' : content;
};
const hasFavicon = (str: string): boolean => {
return str.trim() !== '';
};
const isYoutubeWatchUrl = (url: string): boolean => {
return /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/watch\?v=[\w-]+/.test(url) || /^(https?:\/\/)?(www\.)?youtu\.be\/[\w-]+/.test(url);
};
const getYoutubeThumbnail = (url: string): string => {
let videoId;
if (url.includes('youtu.be')) {
videoId = url.split('youtu.be/')[1];
} else {
videoId = url.match(/[?&]v=([^&]+)/)?.[1];
}
return `https://img.youtube.com/vi/${videoId}/0.jpg`;
};
const getFaviconFromDb = (favicon: string): string => {
return `data:image/png;base64,${favicon}`;
};
const getImageDimensions = (path: string): Promise<string> => {
return new Promise(async (resolve) => {
const img = new Image();
img.onload = () => {
imageLoadError.value = false;
imageLoading.value = false;
resolve(`${img.width}x${img.height}`);
};
img.onerror = (e) => {
console.error('Error loading image:', e);
imageLoadError.value = true;
imageLoading.value = false;
resolve('0x0');
};
try {
imageLoading.value = true;
const dataUrl = await getImageUrl(path);
img.src = dataUrl;
} catch (error) {
console.error('Error getting image URL:', error);
imageLoadError.value = true;
imageLoading.value = false;
resolve('0x0');
}
});
};
const getImageUrl = async (path: string): Promise<string> => {
const isWindows = path.includes('\\');
const separator = isWindows ? '\\' : '/';
const filename = path.split(separator).pop();
try {
imageLoading.value = true;
const base64 = await invoke<string>("read_image", { filename });
if (!base64 || base64.length === 0) {
throw new Error('Received empty image data');
}
const dataUrl = `data:image/png;base64,${base64}`;
imageLoadError.value = false;
imageLoading.value = false;
return dataUrl;
} catch (error) {
console.error('Error reading image file:', error);
imageLoadError.value = true;
imageLoading.value = false;
return '';
}
};
const getComputedImageUrl = (item: HistoryItem): string => {
if (!imageUrls.value[item.id]) {
imageUrls.value[item.id] = '';
getImageUrl(item.content)
.then(url => {
imageUrls.value = { ...imageUrls.value, [item.id]: url };
})
.catch(error => {
console.error('Failed to get image URL:', error);
imageUrls.value = { ...imageUrls.value, [item.id]: '' };
});
}
return imageUrls.value[item.id] || '';
};
const loadHistoryChunk = async (): Promise<void> => {
if (!db.value || isLoading) return;
isLoading = true;
let results: HistoryItem[];
if (searchQuery.value) {
const query = `%${searchQuery.value}%`;
results = await db.value.select(
'SELECT * FROM history WHERE content LIKE ? ORDER BY timestamp DESC LIMIT ? OFFSET ?',
[query, chunkSize, offset]
);
} else {
results = await db.value.select(
'SELECT * FROM history ORDER BY timestamp DESC LIMIT ? OFFSET ?',
[chunkSize, offset]
);
}
if (results.length === 0) {
isLoading = false;
return;
}
const processedChunk = await Promise.all(results.map(async item => {
if (item.content_type === 'image') {
const dimensions = await getImageDimensions(item.content);
getComputedImageUrl(item);
return { ...item, dimensions };
}
return item;
}));
history.value = [...history.value, ...processedChunk];
offset += chunkSize;
isLoading = false;
};
const handleScroll = (): void => {
if (!resultsContainer.value) return;
const { viewport } = resultsContainer.value?.osInstance().elements() ?? {};
const { scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = viewport ?? {};
if (scrollHeight - scrollTop - clientHeight < 100) {
loadHistoryChunk();
}
};
const hideApp = async (): Promise<void> => {
await app.hide();
await window.getCurrentWindow().hide();
};
const focusSearchInput = (): void => {
nextTick(() => {
searchInput.value?.focus();
});
};
const scrollToSelectedItem = (): void => {
nextTick(() => {
if (selectedElement.value && resultsContainer.value) {
const osInstance = resultsContainer.value.osInstance();
const viewport = osInstance?.elements().viewport;
if (!viewport) return;
const viewportRect = viewport.getBoundingClientRect();
const elementRect = selectedElement.value.getBoundingClientRect();
const isAbove = elementRect.top < viewportRect.top;
const isBelow = elementRect.bottom > viewportRect.bottom - 8;
if (isAbove || isBelow) {
let scrollOffset;
if (isAbove && selectedItemIndex.value === 0 && selectedGroupIndex.value === 0) {
scrollOffset = elementRect.top - viewportRect.top - 36;
} else if (isAbove) {
scrollOffset = elementRect.top - viewportRect.top - 8;
} else {
scrollOffset = elementRect.bottom - viewportRect.bottom + 9;
}
viewport.scrollBy({
top: scrollOffset,
behavior: 'smooth'
});
}
}
});
};
watch([selectedGroupIndex, selectedItemIndex], scrollToSelectedItem);
watch(searchQuery, () => {
searchHistory();
});
onMounted(async () => {
db.value = await Database.load('sqlite:data.db');
await loadHistoryChunk();
if (resultsContainer.value) {
resultsContainer.value.osInstance().elements().viewport.addEventListener('scroll', handleScroll);
}
await listen('tauri://focus', async () => {
history.value = [];
offset = 0;
await loadHistoryChunk();
focusSearchInput();
});
await listen('tauri://blur', () => {
if (searchInput.value) {
searchInput.value.blur();
}
});
if (!await isEnabled()) {
await enable()
}
os.value = await platform();
});
</script>
<style lang="scss">
@import '~/assets/css/index.scss';
</style>

53
pages/keybind.vue Normal file
View file

@ -0,0 +1,53 @@
<template>
<div class="bg">
<div class="keybind-container">
<h2>Set New Keybind</h2>
<div
class="keybind-input"
tabindex="0"
@focus="startCapture"
@blur="stopCapture"
ref="keybindInput"
>
{{ currentKeybind || 'Click here, then press your desired key combination' }}
</div>
<button @click="saveKeybind" :disabled="!currentKeybind">Save Keybind</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
const currentKeybind = ref('');
const keybindInput = ref<HTMLElement | null>(null);
const startCapture = async () => {
await invoke('start_keybind_capture');
};
const stopCapture = async () => {
await invoke('stop_keybind_capture');
};
const saveKeybind = () => {
console.log('Saving keybind:', currentKeybind.value);
// Implement saving logic here
};
onMounted(async () => {
const unlisten = await listen('keybind_captured', (event: any) => {
currentKeybind.value = event.payload as string;
});
onUnmounted(() => {
unlisten();
});
});
</script>
<style lang="scss">
@import '~/assets/css/keybind.scss';
</style>

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="20px" height="18px" viewBox="0 0 15 18" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">
<g id="Code" fill-opacity="1">
<path d="M15 0L15 0L15 18L0 18L0 0L15 0Z" id="Code" fill="none" stroke="none" />
<path d="M3.75 16.0714L11.25 16.0714C12.2855 16.0714 13.125 15.208 13.125 14.1429L13.125 8.02672C13.1248 7.5147 12.9269 7.02399 12.5748 6.66239L8.52375 2.493C8.17225 2.13169 7.69568 1.92868 7.19875 1.92857L3.75 1.92857C2.71447 1.92857 1.875 2.79202 1.875 3.85714L1.875 14.1429C1.875 15.208 2.71447 16.0714 3.75 16.0714M15 8.02672C15.0003 7.00424 14.6053 6.02271 13.9018 5.29904L9.85 1.13143C9.1465 0.406921 8.19178 -0.000123228 7.19625 0L3.75 0C1.67893 0 0 1.7269 0 3.85714L0 14.1429C2.98023e-07 16.2731 1.67893 18 3.75 18L11.25 18C13.3211 18 15 16.2731 15 14.1429L15 8.02672ZM8.4 12.2529C8.03443 11.8764 8.03443 11.2665 8.4 10.89L9.6125 9.64286L8.4 8.39571C8.0558 8.01577 8.06595 7.4237 8.42297 7.05648C8.77999 6.68927 9.35561 6.67882 9.725 7.03286L11.6 8.96143C11.9656 9.33791 11.9656 9.94781 11.6 10.3243L9.725 12.2529C9.35898 12.6289 8.76602 12.6289 8.4 12.2529M6.6 8.39571C6.9442 8.01577 6.93404 7.4237 6.57703 7.05649C6.22001 6.68927 5.64439 6.67882 5.275 7.03286L3.4 8.96143C3.03443 9.33791 3.03443 9.94781 3.4 10.3243L5.275 12.2529C5.50871 12.5108 5.86069 12.617 6.19286 12.5298C6.52502 12.4425 6.7844 12.1757 6.86923 11.8341C6.95406 11.4924 6.85081 11.1304 6.6 10.89L5.3875 9.64286L6.6 8.39571Z" id="Shape" fill="#E5DFD5" fill-rule="evenodd" stroke="none" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="20px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">
<g id="Image" fill-opacity="1">
<path d="M18 0L18 0L18 18L0 18L0 0L18 0Z" id="Image" fill="none" stroke="none" />
<path d="M13.8462 2.07692L4.15385 2.07692C3.00679 2.07692 2.07692 3.00679 2.07692 4.15385L2.07692 11.1143L3.40892 10.1451C4.26934 9.51991 5.43685 9.5289 6.28754 10.1672L7.57246 11.1309L10.8512 8.32016C11.7843 7.52111 13.1676 7.54669 14.0705 8.37969L15.9231 10.0897L15.9231 4.15385C15.9231 3.00679 14.9932 2.07692 13.8462 2.07692M18 12.4588L18 4.15385C18 1.85974 16.1403 0 13.8462 0L4.15385 0C1.85974 0 0 1.85974 0 4.15385L0 13.8462C3.30118e-07 16.1403 1.85974 18 4.15385 18L13.8462 18C16.1403 18 18 16.1403 18 13.8462L18 12.4588ZM15.9231 12.9157L12.6623 9.90554C12.5333 9.78671 12.3358 9.78314 12.2026 9.89723L8.29108 13.2508L7.65831 13.7935L6.99231 13.2937L5.04 11.8302C4.91867 11.7398 4.75269 11.7386 4.63015 11.8274L2.07692 13.6814L2.07692 13.8462C2.07692 14.9932 3.00679 15.9231 4.15385 15.9231L13.8462 15.9231C14.9932 15.9231 15.9231 14.9932 15.9231 13.8462L15.9231 12.9157ZM8.30769 6.23077C8.30769 7.37782 7.37782 8.30769 6.23077 8.30769C5.08372 8.30769 4.15385 7.37782 4.15385 6.23077C4.15385 5.08372 5.08372 4.15385 6.23077 4.15385C7.37782 4.15385 8.30769 5.08372 8.30769 6.23077" id="Shape" fill="#E5DFD5" fill-rule="evenodd" stroke="none" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="20px" height="18px" viewBox="0 0 15 18" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">
<g id="Text" fill-opacity="1">
<path d="M15 0L15 0L15 18L0 18L0 0L15 0Z" id="Text" fill="none" stroke="none" />
<path d="M3.75 16.0714L11.25 16.0714C12.2855 16.0714 13.125 15.208 13.125 14.1429L13.125 8.02672C13.1248 7.5147 12.9269 7.02399 12.5748 6.66239L8.52375 2.493C8.17225 2.13169 7.69568 1.92868 7.19875 1.92857L3.75 1.92857C2.71447 1.92857 1.875 2.79202 1.875 3.85714L1.875 14.1429C1.875 15.208 2.71447 16.0714 3.75 16.0714M15 8.02672C15.0003 7.00424 14.6053 6.02271 13.9018 5.29904L9.85 1.13143C9.1465 0.406921 8.19178 -0.000123228 7.19625 0L3.75 0C1.67893 0 0 1.7269 0 3.85714L0 14.1429C2.98023e-07 16.2731 1.67893 18 3.75 18L11.25 18C13.3211 18 15 16.2731 15 14.1429L15 8.02672ZM3.75 9.32143C3.75 8.78887 4.16973 8.35714 4.6875 8.35714L10.3125 8.35714C10.8303 8.35714 11.25 8.78887 11.25 9.32143C11.25 9.85399 10.8303 10.2857 10.3125 10.2857L4.6875 10.2857C4.16973 10.2857 3.75 9.85399 3.75 9.32143M4.6875 12.2143C4.35256 12.2143 4.04307 12.3981 3.8756 12.6964C3.70813 12.9948 3.70813 13.3624 3.8756 13.6607C4.04307 13.9591 4.35256 14.1429 4.6875 14.1429L7.8125 14.1429C8.14744 14.1429 8.45693 13.9591 8.6244 13.6607C8.79187 13.3624 8.79187 12.9948 8.6244 12.6964C8.45693 12.3981 8.14744 12.2143 7.8125 12.2143L4.6875 12.2143Z" id="Shape" fill="#E5DFD5" fill-rule="evenodd" stroke="none" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="20px" height="18px" viewBox="0 0 15 18" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">
<g id="File" fill-opacity="1">
<path d="M15 0L15 0L15 18L0 18L0 0L15 0Z" id="File" fill="none" stroke="none" />
<path d="M11.25 16.0714L3.75 16.0714C2.71447 16.0714 1.875 15.208 1.875 14.1429L1.875 3.85714C1.875 2.79202 2.71447 1.92857 3.75 1.92857L6.25 1.92857L6.25 5.14286C6.25 7.2731 7.92893 9 10 9L13.125 9L13.125 14.1429C13.125 15.208 12.2855 16.0714 11.25 16.0714M12.8788 7.07143C12.7961 6.92188 12.6944 6.78437 12.5763 6.66257L8.5225 2.493C8.40408 2.37151 8.2704 2.26687 8.125 2.18186L8.125 5.14286C8.125 6.20798 8.96447 7.07143 10 7.07143L12.8788 7.07143ZM13.9013 5.29843C14.6049 6.02193 15.0001 7.00338 15 8.02672L15 14.1429C15 16.2731 13.3211 18 11.25 18L3.75 18C1.67893 18 0 16.2731 0 14.1429L0 3.85714C-5.96046e-07 1.7269 1.67893 0 3.75 0L7.19625 0C8.19116 -0.000122309 9.14535 0.406423 9.84875 1.13014L13.9013 5.29843Z" id="Shape" fill="#E5DFD5" fill-rule="evenodd" stroke="none" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -28,6 +28,7 @@
"core:window:allow-show", "core:window:allow-show",
"core:window:allow-set-focus", "core:window:allow-set-focus",
"core:window:allow-is-focused", "core:window:allow-is-focused",
"core:window:allow-is-visible" "core:window:allow-is-visible",
"fs:allow-read"
] ]
} }

View file

@ -24,11 +24,12 @@ pub fn set_app_data_dir(path: std::path::PathBuf) {
} }
#[tauri::command] #[tauri::command]
pub fn read_image(filename: String) -> Result<Vec<u8>, String> { pub fn read_image(filename: String) -> Result<String, String> {
let app_data_dir = APP_DATA_DIR.lock().unwrap(); let app_data_dir = APP_DATA_DIR.lock().unwrap();
let app_data_dir = app_data_dir.as_ref().expect("App data directory not set"); let app_data_dir = app_data_dir.as_ref().expect("App data directory not set");
let image_path = app_data_dir.join("images").join(filename); let image_path = app_data_dir.join("images").join(filename);
fs::read(image_path).map_err(|e| e.to_string()) let image_data = fs::read(image_path).map_err(|e| e.to_string())?;
Ok(STANDARD.encode(image_data))
} }
#[tauri::command] #[tauri::command]
@ -107,26 +108,22 @@ pub fn setup<R: Runtime>(app: &AppHandle<R>) {
let app = app.clone(); let app = app.clone();
runtime.block_on(async move { runtime.block_on(async move {
if IS_PROGRAMMATIC_PASTE.load(Ordering::SeqCst) { if IS_PROGRAMMATIC_PASTE.load(Ordering::SeqCst) {
println!("Ignoring programmatic paste");
return; return;
} }
let clipboard = app.state::<Clipboard>(); let clipboard = app.state::<Clipboard>();
let available_types = clipboard.available_types().unwrap(); let available_types = clipboard.available_types().unwrap();
println!("Clipboard update detected");
match get_pool(&app).await { match get_pool(&app).await {
Ok(pool) => { Ok(pool) => {
if available_types.image { if available_types.image {
println!("Handling image change"); println!("Handling image change");
if let Ok(image_data) = clipboard.read_image_base64() { if let Ok(image_data) = clipboard.read_image_base64() {
let base64_image = STANDARD.encode(&image_data);
insert_content_if_not_exists( insert_content_if_not_exists(
app.clone(), app.clone(),
pool.clone(), pool.clone(),
"image", "image",
base64_image, image_data,
) )
.await; .await;
} }

View file

@ -1,31 +1,136 @@
use rdev::{listen, EventType, Key}; use rdev::{listen, Event, EventType, Key};
use tauri::Manager; use tauri::{Manager, Emitter};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::collections::HashSet;
use serde::Serialize;
use crate::utils::commands::center_window_on_current_monitor; use crate::utils::commands::center_window_on_current_monitor;
static IS_CAPTURING_KEYBIND: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Serialize)]
struct CapturedKeybind {
modifiers: Vec<String>,
key: String,
}
struct KeybindState {
pressed_keys: HashSet<Key>,
}
impl KeybindState {
fn new() -> Self {
Self {
pressed_keys: HashSet::new(),
}
}
}
pub fn setup(app_handle: tauri::AppHandle) { pub fn setup(app_handle: tauri::AppHandle) {
let app_handle_clone = app_handle.clone();
let keybind_state = Arc::new(Mutex::new(KeybindState::new()));
std::thread::spawn(move || { std::thread::spawn(move || {
let mut meta_pressed = false; if let Err(e) = listen(move |event| {
listen(move |event| { let mut state = keybind_state.lock().unwrap();
if IS_CAPTURING_KEYBIND.load(Ordering::SeqCst) {
handle_keybind_capture(&app_handle_clone, event, &mut state);
} else {
handle_normal_hotkey(&app_handle_clone, event, &mut state);
}
}) {
eprintln!("Error setting up event listener: {:?}", e);
}
});
}
fn handle_normal_hotkey(app_handle: &tauri::AppHandle, event: Event, state: &mut KeybindState) {
match event.event_type { match event.event_type {
EventType::KeyPress(Key::MetaLeft) | EventType::KeyPress(Key::MetaRight) => { EventType::KeyPress(Key::MetaLeft) | EventType::KeyPress(Key::MetaRight) => {
meta_pressed = true; state.pressed_keys.insert(Key::MetaLeft);
} }
EventType::KeyRelease(Key::MetaLeft) | EventType::KeyRelease(Key::MetaRight) => { EventType::KeyRelease(Key::MetaLeft) | EventType::KeyRelease(Key::MetaRight) => {
meta_pressed = false; state.pressed_keys.remove(&Key::MetaLeft);
} }
EventType::KeyPress(Key::KeyV) => { EventType::KeyPress(Key::KeyV) => {
if meta_pressed { if state.pressed_keys.contains(&Key::MetaLeft) {
meta_pressed = false; state.pressed_keys.clear();
let window = app_handle.get_webview_window("main").unwrap(); if let Some(window) = app_handle.get_webview_window("main") {
window.show().unwrap(); let _ = window.show();
window.set_focus().unwrap(); let _ = window.set_focus();
center_window_on_current_monitor(&window); center_window_on_current_monitor(&window);
} }
} }
}
_ => {} _ => {}
} }
}) }
.unwrap();
}); fn handle_keybind_capture(app_handle: &tauri::AppHandle, event: Event, state: &mut KeybindState) {
match event.event_type {
EventType::KeyPress(key) => {
state.pressed_keys.insert(key);
update_captured_keybind(app_handle, &state.pressed_keys);
}
EventType::KeyRelease(key) => {
state.pressed_keys.remove(&key);
}
_ => {}
}
}
fn update_captured_keybind(app_handle: &tauri::AppHandle, pressed_keys: &HashSet<Key>) {
let modifiers: Vec<String> = vec![Key::ControlLeft, Key::ShiftLeft, Key::Alt, Key::MetaLeft]
.into_iter()
.filter(|key| pressed_keys.contains(key))
.map(|key| key_to_string(key))
.collect();
let key = pressed_keys.iter()
.find(|&&key| !vec![Key::ControlLeft, Key::ShiftLeft, Key::Alt, Key::MetaLeft].contains(&key))
.map(|&key| key_to_string(key));
if let Some(key) = key {
let captured_keybind = CapturedKeybind {
modifiers,
key,
};
if let Err(e) = app_handle.emit("keybind_captured", captured_keybind) {
eprintln!("Error emitting keybind_captured event: {:?}", e);
}
}
}
fn key_to_string(key: Key) -> String {
match key {
Key::ControlLeft | Key::ControlRight => "Ctrl".to_string(),
Key::ShiftLeft | Key::ShiftRight => "Shift".to_string(),
Key::Alt => "Alt".to_string(),
Key::MetaLeft | Key::MetaRight => "Meta".to_string(),
_ => format!("{:?}", key),
}
}
#[tauri::command]
pub fn start_keybind_capture() {
IS_CAPTURING_KEYBIND.store(true, Ordering::SeqCst);
}
#[tauri::command]
pub fn stop_keybind_capture() {
IS_CAPTURING_KEYBIND.store(false, Ordering::SeqCst);
}
#[tauri::command]
pub fn get_current_keybind() -> String {
// Implement logic to retrieve the current keybind from your configuration
"Meta+V".to_string() // Placeholder
}
#[tauri::command]
pub fn save_keybind(keybind: String) -> Result<(), String> {
// Implement logic to save the new keybind to your configuration
println!("Saving keybind: {}", keybind);
Ok(())
} }

View file

@ -1,13 +1,10 @@
use tauri::{ use tauri::{
Manager, menu::{MenuBuilder, MenuItemBuilder}, tray::TrayIconBuilder, Emitter, Manager
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, TrayIconBuilder, TrayIconEvent},
}; };
pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> { pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let window = app.get_webview_window("main").unwrap(); let window = app.get_webview_window("main").unwrap();
let window_clone_for_tray = window.clone(); let window_clone_for_tray = window.clone();
let window_clone_for_click = window.clone();
let icon_bytes = include_bytes!("../../icons/Square71x71Logo.png"); let icon_bytes = include_bytes!("../../icons/Square71x71Logo.png");
let icon = tauri::image::Image::from_bytes(icon_bytes).unwrap(); let icon = tauri::image::Image::from_bytes(icon_bytes).unwrap();
@ -15,7 +12,11 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let _tray = TrayIconBuilder::new() let _tray = TrayIconBuilder::new()
.menu( .menu(
&MenuBuilder::new(app) &MenuBuilder::new(app)
.items(&[&MenuItemBuilder::with_id("app_name", "Qopy")
.enabled(false)
.build(app)?])
.items(&[&MenuItemBuilder::with_id("show", "Show/Hide").build(app)?]) .items(&[&MenuItemBuilder::with_id("show", "Show/Hide").build(app)?])
.items(&[&MenuItemBuilder::with_id("keybind", "Change keybind").build(app)?])
.items(&[&MenuItemBuilder::with_id("quit", "Quit").build(app)?]) .items(&[&MenuItemBuilder::with_id("quit", "Quit").build(app)?])
.build()?, .build()?,
) )
@ -31,21 +32,13 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
window_clone_for_tray.show().unwrap(); window_clone_for_tray.show().unwrap();
window_clone_for_tray.set_focus().unwrap(); window_clone_for_tray.set_focus().unwrap();
} }
window_clone_for_tray.emit("main_route", ()).unwrap();
}
"keybind" => {
window_clone_for_tray.emit("change_keybind", ()).unwrap();
} }
_ => (), _ => (),
}) })
.on_tray_icon_event(move |_tray, event| {
if let TrayIconEvent::Click { button, .. } = event {
if button == MouseButton::Left {
let is_visible = window_clone_for_click.is_visible().unwrap();
if is_visible {
window_clone_for_click.hide().unwrap();
} else {
window_clone_for_click.show().unwrap();
}
}
}
})
.icon(icon) .icon(icon)
.build(app)?; .build(app)?;

View file

@ -31,6 +31,7 @@ fn main() {
.setup(|app| { .setup(|app| {
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
// #[cfg(not(target_os = "macos"))]
api::hotkeys::setup(app_handle.clone()); api::hotkeys::setup(app_handle.clone());
api::tray::setup(app)?; api::tray::setup(app)?;
api::database::setup(app)?; api::database::setup(app)?;
@ -73,6 +74,10 @@ fn main() {
api::clipboard::get_image_path, api::clipboard::get_image_path,
api::clipboard::write_and_paste, api::clipboard::write_and_paste,
api::clipboard::read_image, api::clipboard::read_image,
api::hotkeys::start_keybind_capture,
api::hotkeys::stop_keybind_capture,
api::hotkeys::get_current_keybind,
api::hotkeys::save_keybind,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");