mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge pull request [#433](https://github.com/mazeincoding/AppCut/issues/433)
This commit is contained in:
@@ -1,49 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { Keyboard } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
type KeyboardShortcut,
|
||||||
|
useKeyboardShortcutsHelp,
|
||||||
|
} from "@/hooks/use-keyboard-shortcuts-help";
|
||||||
|
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "./ui/dialog";
|
} from "./ui/dialog";
|
||||||
import { getPlatformSpecialKey } from "@/lib/utils";
|
|
||||||
import { Keyboard } from "lucide-react";
|
|
||||||
import {
|
|
||||||
useKeyboardShortcutsHelp,
|
|
||||||
KeyboardShortcut,
|
|
||||||
} from "@/hooks/use-keyboard-shortcuts-help";
|
|
||||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
const modifier: {
|
|
||||||
[key: string]: string;
|
|
||||||
} = {
|
|
||||||
Shift: "Shift",
|
|
||||||
Alt: "Alt",
|
|
||||||
ArrowLeft: "←",
|
|
||||||
ArrowRight: "→",
|
|
||||||
ArrowUp: "↑",
|
|
||||||
ArrowDown: "↓",
|
|
||||||
Space: "Space",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getKeyWithModifier(key: string) {
|
|
||||||
if (key === "Ctrl") return getPlatformSpecialKey();
|
|
||||||
return modifier[key] || key;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShortcutItem = ({
|
const ShortcutItem = ({
|
||||||
shortcut,
|
shortcut,
|
||||||
recordingKey,
|
isRecording,
|
||||||
onStartRecording,
|
onStartRecording,
|
||||||
}: {
|
}: {
|
||||||
shortcut: KeyboardShortcut;
|
shortcut: KeyboardShortcut;
|
||||||
recordingKey: string | null;
|
isRecording: boolean;
|
||||||
onStartRecording: (keyId: string, shortcut: KeyboardShortcut) => void;
|
onStartRecording: (shortcut: KeyboardShortcut) => void;
|
||||||
}) => {
|
}) => {
|
||||||
// Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
|
// Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
|
||||||
const displayKeys = shortcut.keys.filter((key: string) => {
|
const displayKeys = shortcut.keys.filter((key: string) => {
|
||||||
@@ -66,20 +49,17 @@ const ShortcutItem = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{displayKeys.map((key: string, index: number) => (
|
{displayKeys.map((key: string, index: number) => (
|
||||||
<div key={index} className="flex items-center gap-1">
|
<div key={key} className="flex items-center gap-1">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{key.split("+").map((keyPart: string, partIndex: number) => {
|
{key.split("+").map((keyPart: string, partIndex: number) => {
|
||||||
const keyId = `${shortcut.id}-${index}-${partIndex}`;
|
const keyId = `${shortcut.id}-${index}-${partIndex}`;
|
||||||
return (
|
return (
|
||||||
<EditableShortcutKey
|
<EditableShortcutKey
|
||||||
key={partIndex}
|
key={keyId}
|
||||||
keyId={keyId}
|
isRecording={isRecording}
|
||||||
originalKey={key}
|
onStartRecording={() => onStartRecording(shortcut)}
|
||||||
shortcut={shortcut}
|
|
||||||
isRecording={recordingKey === keyId}
|
|
||||||
onStartRecording={() => onStartRecording(keyId, shortcut)}
|
|
||||||
>
|
>
|
||||||
{getKeyWithModifier(keyPart)}
|
{keyPart}
|
||||||
</EditableShortcutKey>
|
</EditableShortcutKey>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -96,16 +76,10 @@ const ShortcutItem = ({
|
|||||||
|
|
||||||
const EditableShortcutKey = ({
|
const EditableShortcutKey = ({
|
||||||
children,
|
children,
|
||||||
keyId,
|
|
||||||
originalKey,
|
|
||||||
shortcut,
|
|
||||||
isRecording,
|
isRecording,
|
||||||
onStartRecording,
|
onStartRecording,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
keyId: string;
|
|
||||||
originalKey: string;
|
|
||||||
shortcut: KeyboardShortcut;
|
|
||||||
isRecording: boolean;
|
isRecording: boolean;
|
||||||
onStartRecording: () => void;
|
onStartRecording: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -116,7 +90,9 @@ const EditableShortcutKey = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<kbd
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
className={`inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1 cursor-pointer hover:bg-opacity-80 ${
|
className={`inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1 cursor-pointer hover:bg-opacity-80 ${
|
||||||
isRecording
|
isRecording
|
||||||
? "border-primary bg-primary/10"
|
? "border-primary bg-primary/10"
|
||||||
@@ -128,13 +104,12 @@ const EditableShortcutKey = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</kbd>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KeyboardShortcutsHelp = () => {
|
export const KeyboardShortcutsHelp = () => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [recordingKey, setRecordingKey] = useState<string | null>(null);
|
|
||||||
const [recordingShortcut, setRecordingShortcut] =
|
const [recordingShortcut, setRecordingShortcut] =
|
||||||
useState<KeyboardShortcut | null>(null);
|
useState<KeyboardShortcut | null>(null);
|
||||||
|
|
||||||
@@ -144,6 +119,9 @@ export const KeyboardShortcutsHelp = () => {
|
|||||||
getKeybindingString,
|
getKeybindingString,
|
||||||
validateKeybinding,
|
validateKeybinding,
|
||||||
getKeybindingsForAction,
|
getKeybindingsForAction,
|
||||||
|
setIsRecording,
|
||||||
|
resetToDefaults,
|
||||||
|
isRecording,
|
||||||
} = useKeybindingsStore();
|
} = useKeybindingsStore();
|
||||||
|
|
||||||
// Get shortcuts from centralized hook
|
// Get shortcuts from centralized hook
|
||||||
@@ -152,7 +130,7 @@ export const KeyboardShortcutsHelp = () => {
|
|||||||
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
|
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recordingKey || !recordingShortcut) return;
|
if (!isRecording || !recordingShortcut) return;
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -169,7 +147,6 @@ export const KeyboardShortcutsHelp = () => {
|
|||||||
toast.error(
|
toast.error(
|
||||||
`Key "${keyString}" is already bound to "${conflict.existingAction}"`
|
`Key "${keyString}" is already bound to "${conflict.existingAction}"`
|
||||||
);
|
);
|
||||||
setRecordingKey(null);
|
|
||||||
setRecordingShortcut(null);
|
setRecordingShortcut(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -181,14 +158,14 @@ export const KeyboardShortcutsHelp = () => {
|
|||||||
// Add new keybinding
|
// Add new keybinding
|
||||||
updateKeybinding(keyString, recordingShortcut.action);
|
updateKeybinding(keyString, recordingShortcut.action);
|
||||||
|
|
||||||
setRecordingKey(null);
|
setIsRecording(false);
|
||||||
setRecordingShortcut(null);
|
setRecordingShortcut(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = () => {
|
||||||
setRecordingKey(null);
|
|
||||||
setRecordingShortcut(null);
|
setRecordingShortcut(null);
|
||||||
|
setIsRecording(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
@@ -199,18 +176,19 @@ export const KeyboardShortcutsHelp = () => {
|
|||||||
document.removeEventListener("click", handleClickOutside);
|
document.removeEventListener("click", handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
recordingKey,
|
|
||||||
recordingShortcut,
|
recordingShortcut,
|
||||||
getKeybindingString,
|
getKeybindingString,
|
||||||
updateKeybinding,
|
updateKeybinding,
|
||||||
removeKeybinding,
|
removeKeybinding,
|
||||||
validateKeybinding,
|
validateKeybinding,
|
||||||
getKeybindingsForAction,
|
getKeybindingsForAction,
|
||||||
|
setIsRecording,
|
||||||
|
isRecording,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleStartRecording = (keyId: string, shortcut: KeyboardShortcut) => {
|
const handleStartRecording = (shortcut: KeyboardShortcut) => {
|
||||||
setRecordingKey(keyId);
|
|
||||||
setRecordingShortcut(shortcut);
|
setRecordingShortcut(shortcut);
|
||||||
|
setIsRecording(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -221,7 +199,7 @@ export const KeyboardShortcutsHelp = () => {
|
|||||||
Shortcuts
|
Shortcuts
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Keyboard className="w-5 h-5" />
|
<Keyboard className="w-5 h-5" />
|
||||||
@@ -242,11 +220,13 @@ export const KeyboardShortcutsHelp = () => {
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{shortcuts
|
{shortcuts
|
||||||
.filter((shortcut) => shortcut.category === category)
|
.filter((shortcut) => shortcut.category === category)
|
||||||
.map((shortcut, index) => (
|
.map((shortcut) => (
|
||||||
<ShortcutItem
|
<ShortcutItem
|
||||||
key={index}
|
key={shortcut.action}
|
||||||
shortcut={shortcut}
|
shortcut={shortcut}
|
||||||
recordingKey={recordingKey}
|
isRecording={
|
||||||
|
shortcut.action === recordingShortcut?.action
|
||||||
|
}
|
||||||
onStartRecording={handleStartRecording}
|
onStartRecording={handleStartRecording}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -254,6 +234,16 @@ export const KeyboardShortcutsHelp = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={resetToDefaults}
|
||||||
|
>
|
||||||
|
Reset to Default
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollArea className="max-h-[85vh]">
|
<ScrollArea className="max-h-[75vh]">
|
||||||
<div className="p-6 space-y-4">{children}</div>
|
<div className="p-6 space-y-4">{children}</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
|
|||||||
* the appropriate actions based on keybindings
|
* the appropriate actions based on keybindings
|
||||||
*/
|
*/
|
||||||
export function useKeybindingsListener() {
|
export function useKeybindingsListener() {
|
||||||
const { keybindings, getKeybindingString, keybindingsEnabled } =
|
const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } =
|
||||||
useKeybindingsStore();
|
useKeybindingsStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||||
// Do not check keybinds if the mode is disabled
|
// Do not check keybinds if the mode is disabled
|
||||||
if (!keybindingsEnabled) return;
|
if (!keybindingsEnabled) return;
|
||||||
|
// ignore key events if user is changing keybindings
|
||||||
|
if (isRecording) return;
|
||||||
|
|
||||||
const binding = getKeybindingString(ev);
|
const binding = getKeybindingString(ev);
|
||||||
if (!binding) return;
|
if (!binding) return;
|
||||||
@@ -45,7 +47,7 @@ export function useKeybindingsListener() {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [keybindings, getKeybindingString, keybindingsEnabled]);
|
}, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||||
import { Action } from "@/constants/actions";
|
import { Action } from "@/constants/actions";
|
||||||
|
import { getPlatformAlternateKey, getPlatformSpecialKey } from "@/lib/utils";
|
||||||
|
|
||||||
export interface KeyboardShortcut {
|
export interface KeyboardShortcut {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -67,13 +68,13 @@ const actionDescriptions: Record<
|
|||||||
// Convert key binding format to display format
|
// Convert key binding format to display format
|
||||||
const formatKey = (key: string): string => {
|
const formatKey = (key: string): string => {
|
||||||
return key
|
return key
|
||||||
.replace("ctrl", "Cmd")
|
.replace("ctrl", getPlatformSpecialKey())
|
||||||
.replace("alt", "Alt")
|
.replace("alt", getPlatformAlternateKey())
|
||||||
.replace("shift", "Shift")
|
.replace("shift", "Shift")
|
||||||
.replace("left", "ArrowLeft")
|
.replace("left", "←")
|
||||||
.replace("right", "ArrowRight")
|
.replace("right", "→")
|
||||||
.replace("up", "ArrowUp")
|
.replace("up", "↑")
|
||||||
.replace("down", "ArrowDown")
|
.replace("down", "↓")
|
||||||
.replace("space", "Space")
|
.replace("space", "Space")
|
||||||
.replace("home", "Home")
|
.replace("home", "Home")
|
||||||
.replace("end", "End")
|
.replace("end", "End")
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface KeybindingsState {
|
|||||||
keybindings: KeybindingConfig;
|
keybindings: KeybindingConfig;
|
||||||
isCustomized: boolean;
|
isCustomized: boolean;
|
||||||
keybindingsEnabled: boolean;
|
keybindingsEnabled: boolean;
|
||||||
|
isRecording: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void;
|
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void;
|
||||||
@@ -48,6 +49,7 @@ interface KeybindingsState {
|
|||||||
exportKeybindings: () => KeybindingConfig;
|
exportKeybindings: () => KeybindingConfig;
|
||||||
enableKeybindings: () => void;
|
enableKeybindings: () => void;
|
||||||
disableKeybindings: () => void;
|
disableKeybindings: () => void;
|
||||||
|
setIsRecording: (isRecording: boolean) => void;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
validateKeybinding: (
|
validateKeybinding: (
|
||||||
@@ -66,6 +68,7 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
|||||||
keybindings: { ...defaultKeybindings },
|
keybindings: { ...defaultKeybindings },
|
||||||
isCustomized: false,
|
isCustomized: false,
|
||||||
keybindingsEnabled: true,
|
keybindingsEnabled: true,
|
||||||
|
isRecording: false,
|
||||||
|
|
||||||
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => {
|
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -141,6 +144,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
setIsRecording: (isRecording: boolean) => {
|
||||||
|
set({ isRecording });
|
||||||
|
},
|
||||||
|
|
||||||
getKeybindingsForAction: (action: ActionWithOptionalArgs) => {
|
getKeybindingsForAction: (action: ActionWithOptionalArgs) => {
|
||||||
const { keybindings } = get();
|
const { keybindings } = get();
|
||||||
@@ -211,8 +217,12 @@ function getPressedKey(ev: KeyboardEvent): string | null {
|
|||||||
if (key === "backspace") return "backspace";
|
if (key === "backspace") return "backspace";
|
||||||
|
|
||||||
// Check letter keys
|
// Check letter keys
|
||||||
const isLetter = key.length === 1 && key >= "a" && key <= "z";
|
if (code.startsWith("Key")) {
|
||||||
if (isLetter) return key;
|
const letter = code.slice(3).toLowerCase();
|
||||||
|
if (letter.length === 1 && letter >= "a" && letter <= "z") {
|
||||||
|
return letter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check number keys using physical position for AZERTY support
|
// Check number keys using physical position for AZERTY support
|
||||||
if (code.startsWith("Digit")) {
|
if (code.startsWith("Digit")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user