Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 86 additions & 25 deletions frontend/src/components/KeyboardShortcutsModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,52 +3,71 @@
v-model="showDialog"
:options="{
title: 'Keyboard Shortcuts',
size: '4xl',
size: '6xl',
}">
<template #body-content>
<div class="max-h-[70vh] columns-2 gap-8 overflow-y-auto">
<div
v-for="(shortcuts, group) in groupedShortcuts"
:key="group"
class="mb-7 break-inside-avoid last:mb-0">
<h3 class="mb-2 text-base font-medium tracking-wider text-ink-gray-8">
{{ group }}
</h3>
<div class="flex flex-col">
<div class="flex max-h-[70vh] flex-col">
<div v-if="shouldShowSearch" class="mb-5 w-fit">
<Input
v-model.trim="searchQuery"
@input="searchQuery = $event"
type="text"
placeholder="Search shortcuts" />
</div>
<div class="grid grid-cols-1 gap-x-5 gap-y-4 overflow-y-auto pr-1 md:grid-cols-2 xl:grid-cols-3">
<div v-for="(shortcuts, group) in filteredGroupedShortcuts" :key="group" class="space-y-1.5">
<h3 class="text-base font-medium tracking-wide text-ink-gray-8">
{{ group }}
</h3>
<div
v-for="shortcut in shortcuts"
:key="shortcut.description"
class="flex items-center justify-between rounded py-1">
<span class="text-base text-ink-gray-6">{{ shortcut.description }}</span>
<div class="flex items-center gap-1">
<kbd
v-for="(part, i) in formatShortcutParts(shortcut)"
:key="i"
class="inline-flex h-6 min-w-6 items-center justify-center rounded border bg-surface-gray-2 px-1.5 py-0.5 font-medium text-ink-gray-7"
:class="{
'text-xs': !part.isSymbol,
}">
{{ part.label }}
</kbd>
:key="shortcut.id"
class="flex items-start justify-between gap-3 rounded py-0.5">
<span class="text-p-base text-ink-gray-6">{{ shortcut.description }}</span>
<div class="flex shrink-0 items-center gap-1.5">
<div
v-for="(variant, variantIndex) in formatShortcutVariants(shortcut)"
:key="`${shortcut.id.toString()}-${variantIndex}`"
class="flex items-center gap-1">
<kbd
v-for="(part, i) in variant"
:key="`${variantIndex}-${i}`"
class="inline-flex h-6 min-w-6 items-center justify-center rounded border bg-surface-gray-2 px-1.5 py-0.5 font-medium text-ink-gray-7"
:class="{
'text-xs': !part.isSymbol,
}">
{{ part.label }}
</kbd>
<span v-if="variantIndex < shortcut.keys.length - 1" class="px-0.5 text-xs text-ink-gray-5">
/
</span>
</div>
</div>
</div>
</div>
</div>

<div v-if="!activeShortcuts.length" class="py-8 text-center text-sm text-ink-gray-5">
No keyboard shortcuts available on this page.
</div>
<div
v-else-if="shouldShowSearch && !hasVisibleShortcuts"
class="py-8 text-center text-sm text-ink-gray-5">
No shortcuts match your search.
</div>
</div>
</template>
</Dialog>
</template>

<script setup lang="ts">
import Dialog from "@/components/Controls/Dialog.vue";
import { getActiveShortcuts, type RegisteredShortcut } from "@/utils/useShortcut";
import { getActiveShortcuts, type ActiveShortcut } from "@/utils/useShortcut";
import { computed, ref } from "vue";

const showDialog = ref(false);
const activeShortcuts = getActiveShortcuts();
const searchQuery = ref("");

const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;

Expand Down Expand Up @@ -81,8 +100,18 @@ function formatShortcutParts(config: {
return parts;
}

function formatShortcutVariants(shortcut: ActiveShortcut): { label: string; isSymbol: boolean }[][] {
return shortcut.keys.map((key) =>
formatShortcutParts({
key,
ctrl: shortcut.ctrl,
shift: shortcut.shift,
}),
);
}

const groupedShortcuts = computed(() => {
const groups: Record<string, RegisteredShortcut[]> = {};
const groups: Record<string, ActiveShortcut[]> = {};
for (const shortcut of activeShortcuts.value) {
if (!groups[shortcut.group]) {
groups[shortcut.group] = [];
Expand All @@ -92,5 +121,37 @@ const groupedShortcuts = computed(() => {
return groups;
});

const shouldShowSearch = computed(() => activeShortcuts.value.length > 20);

const filteredGroupedShortcuts = computed(() => {
if (!shouldShowSearch.value || !searchQuery.value) {
return groupedShortcuts.value;
}

const query = searchQuery.value.toLowerCase();
const filtered: Record<string, ActiveShortcut[]> = {};

for (const [group, shortcuts] of Object.entries(groupedShortcuts.value)) {
const groupMatches = group.toLowerCase().includes(query);
const matchingShortcuts = groupMatches
? shortcuts
: shortcuts.filter((shortcut) => {
const keyParts = formatShortcutVariants(shortcut)
.flat()
.map((part) => part.label.toLowerCase())
.join(" ");
return shortcut.description.toLowerCase().includes(query) || keyParts.includes(query);
});

if (matchingShortcuts.length) {
filtered[group] = matchingShortcuts;
}
}

return filtered;
});

const hasVisibleShortcuts = computed(() => Object.keys(filteredGroupedShortcuts.value).length > 0);

defineExpose({ showDialog });
</script>
17 changes: 16 additions & 1 deletion frontend/src/pages/PageBuilder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ useShortcut([
key: "i",
ctrl: true,
description: "Edit block with AI",
group: "Block",
group: "Edit",
condition: () =>
builderStore.isAIEnabled &&
!blockController.isRoot() &&
Expand All @@ -323,6 +323,21 @@ useShortcut([
}
},
},
{
key: "d",
ctrl: true,
shift: true,
description: "Delete Page",
group: "Page",
handler: () => {
if (pageStore.activePage && !pageStore.activePage.is_standard) {
pageStore.deletePage(pageStore.activePage).then(() => {
router.push({ name: "home" });
});
}
},
condition: () => Boolean(pageStore.activePage && !pageStore.activePage.is_standard),
},
]);

// When space is released, revert back to last mode
Expand Down
36 changes: 35 additions & 1 deletion frontend/src/utils/useShortcut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ export interface RegisteredShortcut {
condition?: () => boolean;
}

export interface ActiveShortcut extends RegisteredShortcut {
keys: string[];
}

function getShortcutMergeIdentity(
shortcut: Pick<RegisteredShortcut, "group" | "description" | "ctrl" | "shift">,
) {
return [shortcut.group, shortcut.description, Boolean(shortcut.ctrl), Boolean(shortcut.shift)].join("|");
}

const activeShortcuts = reactive<RegisteredShortcut[]>([]);
const shortcutHandlers = new Map<symbol, ShortcutConfig>();

Expand Down Expand Up @@ -274,7 +284,31 @@ export function useShortcut(shortcuts: ShortcutConfig | ShortcutConfig[]) {
* Get all currently registered shortcuts whose conditions are met (read-only).
*/
export function getActiveShortcuts() {
return computed(() => activeShortcuts.filter((s) => !s.condition || s.condition()));
return computed(() => {
const visibleShortcuts = activeShortcuts.filter(
(shortcut) => !shortcut.condition || shortcut.condition(),
);
const mergedShortcuts = new Map<string, ActiveShortcut>();

for (const shortcut of visibleShortcuts) {
const identity = getShortcutMergeIdentity(shortcut);
const existing = mergedShortcuts.get(identity);

if (!existing) {
mergedShortcuts.set(identity, {
...shortcut,
keys: [shortcut.key],
});
continue;
}

if (!existing.keys.some((key) => key.toLowerCase() === shortcut.key.toLowerCase())) {
existing.keys.push(shortcut.key);
}
}

return [...mergedShortcuts.values()];
});
}

export { formatShortcutLabel };
Loading