mirror of
https://github.com/0PandaDEV/Qopy.git
synced 2025-04-21 13:14:04 +02:00
feat: add ActionsMenu component for enhanced action management with search functionality and keyboard navigation
This commit is contained in:
parent
7ba418f4cc
commit
2865f8749e
4 changed files with 747 additions and 29 deletions
702
components/ActionsMenu.vue
Normal file
702
components/ActionsMenu.vue
Normal file
|
@ -0,0 +1,702 @@
|
||||||
|
<template>
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
">
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
">
|
||||||
|
<component v-if="action.icon" :is="action.icon" class="icon" />
|
||||||
|
<div class="content">
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from "vue";
|
||||||
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
|
||||||
|
import "overlayscrollbars/overlayscrollbars.css";
|
||||||
|
import Enter from "./Icons/Enter.vue";
|
||||||
|
import Ctrl from "./Icons/Ctrl.vue";
|
||||||
|
import Cmd from "./Icons/Cmd.vue";
|
||||||
|
import Key from "./Icons/Key.vue";
|
||||||
|
import { ContentType, HistoryItem } from "../types/types";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useNuxtApp } from "#app";
|
||||||
|
|
||||||
|
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 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: "action", action: string, item?: HistoryItem): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
interface ActionItem {
|
||||||
|
title: string;
|
||||||
|
action: string;
|
||||||
|
shortcut?: string;
|
||||||
|
icon?: any;
|
||||||
|
group: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topActions = computed(() => [
|
||||||
|
{
|
||||||
|
title: `Paste to ${currentAppInfo.value.name || "Current App"}`,
|
||||||
|
shortcut: "Enter",
|
||||||
|
action: "paste-to-app",
|
||||||
|
group: "top",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Copy to Clipboard",
|
||||||
|
shortcut: "Ctrl+C",
|
||||||
|
action: "copy",
|
||||||
|
group: "top",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bottomActions = computed(() => [
|
||||||
|
{
|
||||||
|
title: "Delete Entry",
|
||||||
|
shortcut: "Del",
|
||||||
|
action: "delete",
|
||||||
|
group: "bottom",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Delete All Entries",
|
||||||
|
action: "delete-all",
|
||||||
|
group: "bottom",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
action: "settings",
|
||||||
|
group: "bottom",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const textActions = computed(() => [
|
||||||
|
{
|
||||||
|
title: "Paste as plain text",
|
||||||
|
action: "paste-plain",
|
||||||
|
shortcut: "",
|
||||||
|
group: "text",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Edit text",
|
||||||
|
action: "edit-text",
|
||||||
|
shortcut: "",
|
||||||
|
group: "text",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const imageActions = computed(() => [
|
||||||
|
{
|
||||||
|
title: "Rotate",
|
||||||
|
action: "rotate-image",
|
||||||
|
shortcut: "",
|
||||||
|
group: "image",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Resize",
|
||||||
|
action: "resize-image",
|
||||||
|
shortcut: "",
|
||||||
|
group: "image",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Compress",
|
||||||
|
action: "compress-image",
|
||||||
|
shortcut: "",
|
||||||
|
group: "image",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fileActions = computed(() => [
|
||||||
|
{
|
||||||
|
title: "Open",
|
||||||
|
action: "open-file",
|
||||||
|
shortcut: "",
|
||||||
|
group: "file",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Compress to zip/7z",
|
||||||
|
action: "compress-file",
|
||||||
|
shortcut: "",
|
||||||
|
group: "file",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const linkActions = computed(() => [
|
||||||
|
{
|
||||||
|
title: "Open in Browser",
|
||||||
|
action: "open-link",
|
||||||
|
shortcut: "",
|
||||||
|
group: "link",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const colorActions = computed(() => [
|
||||||
|
{
|
||||||
|
title: "Copy as HEX",
|
||||||
|
action: "copy-hex",
|
||||||
|
shortcut: "",
|
||||||
|
group: "color",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Copy as RGB(a)",
|
||||||
|
action: "copy-rgba",
|
||||||
|
shortcut: "",
|
||||||
|
group: "color",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Copy as HSL(a)",
|
||||||
|
action: "copy-hsla",
|
||||||
|
shortcut: "",
|
||||||
|
group: "color",
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
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() === "ctrl") {
|
||||||
|
keyPart = { type: "modifier", value: trimmedPart, component: Ctrl };
|
||||||
|
} else if (trimmedPart.toLowerCase() === "cmd") {
|
||||||
|
keyPart = { type: "modifier", value: trimmedPart, component: Cmd };
|
||||||
|
} 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");
|
||||||
|
emit("action", action.action, props.selectedItem);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
getAppInfo();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener("click", handleClickOutside);
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
|
@ -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>
|
|
33
components/Icons/Key.vue
Normal file
33
components/Icons/Key.vue
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<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="Key" fill-opacity="1">
|
||||||
|
<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" />
|
||||||
|
<text
|
||||||
|
x="50%"
|
||||||
|
y="55%"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
text-anchor="middle"
|
||||||
|
fill="#E5E0D5"
|
||||||
|
font-size="12">
|
||||||
|
{{ input }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
input: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
12
components/Icons/Shift.vue
Normal file
12
components/Icons/Shift.vue
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue