mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
feat: add persisted shortcuts denylist with UI for masking shortcuts
Add a persisted shortcuts preferences store that allows users to disable specific keyboard shortcuts via the Shortcuts modal (Ctrl+Shift+?). Each shortcut now has a toggle button - disabled shortcuts show a block icon, strikethrough description, and are skipped by both the global handler and the Composer's hardcoded shortcuts (Ctrl+Enter, Alt+Enter). Denied shortcuts are also hidden from the StatusBar. Closes #895 Co-authored-by: Enrico Ros <enricoros@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { ShortcutKey, ShortcutObject } from '~/common/components/shortcuts/useGl
|
||||
import { ConfirmationModal } from '~/common/components/modals/ConfirmationModal';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { useGlobalShortcutsStore } from '~/common/components/shortcuts/store-global-shortcuts';
|
||||
import { useShortcutsPreferencesStore, shortcutFingerprint } from '~/common/components/shortcuts/store-shortcuts-preferences';
|
||||
import { useOverlayComponents } from '~/common/layout/overlays/useOverlayComponents';
|
||||
import { useUXLabsStore } from '~/common/stores/store-ux-labs';
|
||||
|
||||
@@ -176,9 +177,12 @@ export function StatusBar(props: { toggleMinimized?: () => void, isMinimized?: b
|
||||
|
||||
// external state
|
||||
const labsShowShortcutBar = useUXLabsStore(state => state.labsShowShortcutBar);
|
||||
const disabledShortcuts = useShortcutsPreferencesStore(state => state.disabledShortcuts);
|
||||
const shortcuts = useGlobalShortcutsStore(useShallow(state => {
|
||||
// get visible shortcuts
|
||||
let visibleShortcuts = !labsShowShortcutBar ? [] : state.getAllShortcuts().filter(shortcut => !!shortcut.description);
|
||||
// get visible shortcuts, excluding user-denied ones
|
||||
let visibleShortcuts = !labsShowShortcutBar ? [] : state.getAllShortcuts().filter(shortcut =>
|
||||
!!shortcut.description && !disabledShortcuts.includes(shortcutFingerprint(shortcut)),
|
||||
);
|
||||
|
||||
// filter by highest level if levels are present
|
||||
const maxLevel = Math.max(...visibleShortcuts.map(s => s.level ?? 0));
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ConversationsManager } from '~/common/chat-overlay/ConversationsManager
|
||||
import { DMessageId, DMessageMetadata, DMetaReferenceItem, messageFragmentsReduceText } from '~/common/stores/chat/chat.message';
|
||||
import { PhPaintBrush } from '~/common/components/icons/phosphor/PhPaintBrush';
|
||||
import { ShortcutKey, ShortcutObject, useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts';
|
||||
import { isShortcutDenied } from '~/common/components/shortcuts/store-shortcuts-preferences';
|
||||
import { addSnackbar } from '~/common/components/snackbar/useSnackbarsStore';
|
||||
import { animationEnterBelow } from '~/common/util/animUtils';
|
||||
import { browserSpeechRecognitionCapability, PLACEHOLDER_INTERIM_TRANSCRIPT, SpeechResult, useSpeechRecognition } from '~/common/components/speechrecognition/useSpeechRecognition';
|
||||
@@ -545,16 +546,20 @@ export function Composer(props: {
|
||||
|
||||
// Alt (Windows) or Option (Mac) + Enter: append the message instead of sending it
|
||||
if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
if (await handleSendAction('append-user', composeText)) // 'alt+enter' -> write
|
||||
e.stopPropagation();
|
||||
return e.preventDefault();
|
||||
if (!isShortcutDenied({ key: 'Enter', alt: true })) {
|
||||
if (await handleSendAction('append-user', composeText)) // 'alt+enter' -> write
|
||||
e.stopPropagation();
|
||||
return e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl (Windows) or Command (Mac) + Enter: send for beaming
|
||||
if (e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam
|
||||
e.stopPropagation();
|
||||
return e.preventDefault();
|
||||
if (!isShortcutDenied({ key: 'Enter', ctrl: true })) {
|
||||
if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam
|
||||
e.stopPropagation();
|
||||
return e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Shift: toggles the 'enter is newline'
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Chip, Divider, Typography } from '@mui/joy';
|
||||
import { Box, Chip, Divider, IconButton, Tooltip, Typography } from '@mui/joy';
|
||||
import BlockIcon from '@mui/icons-material/Block';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
|
||||
import { GoodModal } from '~/common/components/modals/GoodModal';
|
||||
import type { ShortcutDefinition } from '~/common/components/shortcuts/useGlobalShortcuts';
|
||||
import { shortcutsCatalog } from '~/common/components/shortcuts/shortcutsCatalog';
|
||||
import { shortcutFingerprint, useShortcutsPreferencesStore } from '~/common/components/shortcuts/store-shortcuts-preferences';
|
||||
import { useGlobalShortcutsStore } from '~/common/components/shortcuts/store-global-shortcuts';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { Is } from '~/common/util/pwaUtils';
|
||||
@@ -36,6 +39,11 @@ const _styles = {
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
},
|
||||
rowLeft: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
},
|
||||
keys: {
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
@@ -111,6 +119,42 @@ function ShortcutKeyCombo(props: { def: ShortcutDefinition }) {
|
||||
}
|
||||
|
||||
|
||||
function ShortcutRow(props: { item: typeof shortcutsCatalog[number]['items'][number], active: boolean }) {
|
||||
const { item, active } = props;
|
||||
|
||||
const fp = shortcutFingerprint(item);
|
||||
const isDenied = useShortcutsPreferencesStore((state) => state.disabledShortcuts.includes(fp));
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
useShortcutsPreferencesStore.getState().toggleShortcutDisabled(fp);
|
||||
}, [fp]);
|
||||
|
||||
const effectivelyActive = active && !isDenied;
|
||||
|
||||
return (
|
||||
<Box sx={_styles.row}>
|
||||
<Box sx={_styles.rowLeft}>
|
||||
<Tooltip title={isDenied ? 'Enable shortcut' : 'Disable shortcut'} variant='soft'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant={isDenied ? 'soft' : 'plain'}
|
||||
color={isDenied ? 'danger' : 'neutral'}
|
||||
onClick={handleToggle}
|
||||
sx={{ minWidth: 28, minHeight: 28 }}
|
||||
>
|
||||
{isDenied ? <BlockIcon sx={{ fontSize: 16 }} /> : <CheckCircleOutlineIcon sx={{ fontSize: 16, opacity: 0.5 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ShortcutKeyCombo def={item} />
|
||||
</Box>
|
||||
<Typography level='body-xs' sx={!effectivelyActive ? { opacity: 0.5, textDecoration: isDenied ? 'line-through' : undefined } : undefined}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function ShortcutsModal(props: { onClose: () => void }) {
|
||||
|
||||
// external state
|
||||
@@ -130,14 +174,7 @@ export function ShortcutsModal(props: { onClose: () => void }) {
|
||||
</Typography>
|
||||
{category.items.map((item, i) => {
|
||||
const active = _isActive(item, activeFingerprints);
|
||||
return (
|
||||
<Box key={i} sx={_styles.row}>
|
||||
<ShortcutKeyCombo def={item} />
|
||||
<Typography level='body-xs' sx={!active ? { opacity: 0.5 } : undefined}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
return <ShortcutRow key={i} item={item} active={active} />;
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useGlobalShortcutsStore } from './store-global-shortcuts';
|
||||
import { isShortcutDenied } from './store-shortcuts-preferences';
|
||||
|
||||
|
||||
export function ensureGlobalShortcutHandler() {
|
||||
@@ -41,6 +42,10 @@ function _handleGlobalShortcutKeyDown(event: KeyboardEvent) {
|
||||
if (shortcut.skipIfInput && _isTextInputFocused())
|
||||
continue;
|
||||
|
||||
// Skip if the shortcut is on the user's denylist
|
||||
if (isShortcutDenied(shortcut))
|
||||
continue;
|
||||
|
||||
// Execute the action (and prevent the default browser action)
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import type { ShortcutDefinition } from './useGlobalShortcuts';
|
||||
|
||||
|
||||
export function shortcutFingerprint(def: Pick<ShortcutDefinition, 'key' | 'ctrl' | 'shift' | 'alt'>): string {
|
||||
return `${def.key.toLowerCase()}:${!!def.ctrl}:${!!def.shift}:${!!def.alt}`;
|
||||
}
|
||||
|
||||
|
||||
interface ShortcutsPreferencesStore {
|
||||
disabledShortcuts: string[];
|
||||
toggleShortcutDisabled: (fingerprint: string) => void;
|
||||
}
|
||||
|
||||
export const useShortcutsPreferencesStore = create<ShortcutsPreferencesStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
|
||||
disabledShortcuts: [],
|
||||
|
||||
toggleShortcutDisabled: (fingerprint: string) =>
|
||||
set((state) => {
|
||||
const idx = state.disabledShortcuts.indexOf(fingerprint);
|
||||
if (idx >= 0)
|
||||
return { disabledShortcuts: state.disabledShortcuts.filter((f) => f !== fingerprint) };
|
||||
return { disabledShortcuts: [...state.disabledShortcuts, fingerprint] };
|
||||
}),
|
||||
|
||||
}),
|
||||
{
|
||||
name: 'app-shortcuts-preferences',
|
||||
version: 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
export function isShortcutDenied(def: Pick<ShortcutDefinition, 'key' | 'ctrl' | 'shift' | 'alt'>): boolean {
|
||||
const fp = shortcutFingerprint(def);
|
||||
return useShortcutsPreferencesStore.getState().disabledShortcuts.includes(fp);
|
||||
}
|
||||
Reference in New Issue
Block a user