diff --git a/web/app/layout.tsx b/web/app/layout.tsx index ddcb409a..09608b34 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -8,6 +8,7 @@ import { ClerkProviderClient } from "@/components/custom/clerk/clerk-provider-cl import { JotaiProvider } from "@/lib/providers/jotai-provider" import { Toaster } from "@/components/ui/sonner" import { ConfirmProvider } from "@/lib/providers/confirm-provider" +import { KeybindProvider } from "@/lib/providers/keybind-provider" const fontSans = FontSans({ subsets: ["latin"], @@ -28,14 +29,16 @@ export default function RootLayout({ - - - - {children} - - - - + + + + + {children} + + + + + diff --git a/web/components/routes/SettingsRoute.tsx b/web/components/routes/SettingsRoute.tsx index 26a3f9e2..2d7e974c 100644 --- a/web/components/routes/SettingsRoute.tsx +++ b/web/components/routes/SettingsRoute.tsx @@ -1,9 +1,12 @@ "use client" import { useAccount } from "@/lib/providers/jazz-provider" -import { useState, useCallback, useEffect } from "react" +import { useState, useCallback, useEffect, useRef } from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { toast } from "sonner" +import { Icon } from "../la-editor/components/ui/icon" +import { motion } from "framer-motion" +import { useKeybind } from "@/lib/providers/keybind-provider" // Import the hook const MODIFIER_KEYS = ["Control", "Alt", "Shift", "Meta"] @@ -18,6 +21,13 @@ const HotkeyInput = ({ }) => { const [recording, setRecording] = useState(false) const [currentKeys, setCurrentKeys] = useState([]) + const [isHovering, setIsHovering] = useState(false) + const recordingTimeoutRef = useRef(null) + + const stopRecording = useCallback(() => { + setRecording(false) + setCurrentKeys([]) + }, []) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -30,6 +40,10 @@ const HotkeyInput = ({ return newKeys.slice(-3) }) } + // Clear the timeout on each keydown + if (recordingTimeoutRef.current) { + clearTimeout(recordingTimeoutRef.current) + } }, [recording, currentKeys] ) @@ -41,13 +55,18 @@ const HotkeyInput = ({ if (MODIFIER_KEYS.includes(key)) return if (currentKeys.length > 0) { onChange(currentKeys.join("+")) - setRecording(false) - setCurrentKeys([]) + // Set a timeout to stop recording if no key is pressed + recordingTimeoutRef.current = setTimeout(stopRecording, 500) } }, - [recording, currentKeys, onChange] + [recording, currentKeys, onChange, stopRecording] ) + const handleClearKeybind = () => { + onChange("") + setCurrentKeys([]) + } + useEffect(() => { if (recording) { const handleKeyDownEvent = (e: KeyboardEvent) => handleKeyDown(e as unknown as React.KeyboardEvent) @@ -57,6 +76,9 @@ const HotkeyInput = ({ return () => { window.removeEventListener("keydown", handleKeyDownEvent) window.removeEventListener("keyup", handleKeyUpEvent) + if (recordingTimeoutRef.current) { + clearTimeout(recordingTimeoutRef.current) + } } } }, [recording, handleKeyDown, handleKeyUp]) @@ -65,19 +87,37 @@ const HotkeyInput = ({ {label} - setRecording(true)} - /> + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + setRecording(true)} + onBlur={stopRecording} + /> + {isHovering && value && ( + + + + )} + { if (recording) { - setRecording(false) - setCurrentKeys([]) + stopRecording() } else { setRecording(true) } @@ -92,15 +132,42 @@ const HotkeyInput = ({ } export const SettingsRoute = () => { - // const { me } = useAccount() const [inboxHotkey, setInboxHotkey] = useState("") const [topInboxHotkey, setTopInboxHotkey] = useState("") + const { addKeybind, removeKeybind } = useKeybind() + const prevInboxHotkeyRef = useRef("") + const prevTopInboxHotkeyRef = useRef("") + + const updateKeybind = useCallback( + (prevKey: string, newKey: string, action: string, setter: React.Dispatch>) => { + if (prevKey) removeKeybind(prevKey) + if (newKey) { + const existingKeybind = [inboxHotkey, topInboxHotkey].find(hotkey => hotkey === newKey && hotkey !== prevKey) + if (existingKeybind) { + removeKeybind(existingKeybind) + if (existingKeybind === inboxHotkey) { + setInboxHotkey("") + prevInboxHotkeyRef.current = "" + } else { + setTopInboxHotkey("") + prevTopInboxHotkeyRef.current = "" + } + toast.info("Keybind conflict resolved", { + description: `The keybind "${newKey}" was removed from its previous action.` + }) + } + addKeybind({ key: newKey, callback: () => console.log(`${action} action`) }) + setter(newKey) // Update the state with the new keybind + } + }, + [addKeybind, removeKeybind, inboxHotkey, topInboxHotkey] + ) const saveSettings = () => { - console.log("Saving settings:", { inboxHotkey, topInboxHotkey }) - toast.success("Settings saved", { - description: "Your hotkey settings have been updated." - }) + updateKeybind(prevInboxHotkeyRef.current, inboxHotkey, "Save to Inbox", setInboxHotkey) + updateKeybind(prevTopInboxHotkeyRef.current, topInboxHotkey, "Save to Inbox (Top)", setTopInboxHotkey) + prevInboxHotkeyRef.current = inboxHotkey + prevTopInboxHotkeyRef.current = topInboxHotkey } return ( diff --git a/web/components/routes/link/bottom-bar.tsx b/web/components/routes/link/bottom-bar.tsx index 06d6a944..e41f1f8b 100644 --- a/web/components/routes/link/bottom-bar.tsx +++ b/web/components/routes/link/bottom-bar.tsx @@ -14,6 +14,7 @@ import { PersonalLink } from "@/lib/schema" import { ID } from "jazz-tools" import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form" import { toast } from "sonner" +import { useKeybind } from "@/lib/providers/keybind-provider" interface ToolbarButtonProps { icon: keyof typeof icons @@ -64,6 +65,8 @@ export const LinkBottomBar: React.FC = () => { const plusBtnRef = useRef(null) const plusMoreBtnRef = useRef(null) + const { addKeybind, removeKeybind } = useKeybind() + const confirm = useConfirm() useEffect(() => { @@ -151,6 +154,13 @@ export const LinkBottomBar: React.FC = () => { const shortcutKeys = getSpecialShortcut("expandToolbar") const shortcutText = formatShortcut(shortcutKeys) + useEffect(() => { + if (personalLink) { + addKeybind({ key: "Control+x", callback: handleDelete }) + return () => removeKeybind("Control+x") + } + }, [personalLink]) + return ( void +} + +type KeybindContextType = { + addKeybind: (keybind: Keybind) => void + removeKeybind: (key: string) => void +} + +const KeybindContext = createContext(undefined) + +export const KeybindProvider = ({ children }: { children: React.ReactNode }) => { + const [keybinds, setKeybinds] = useState([]) + const [pressedKeys, setPressedKeys] = useState>(new Set()) + + const addKeybind = (keybind: Keybind) => { + setKeybinds(prev => [...prev, keybind]) + } + + const removeKeybind = (key: string) => { + setKeybinds(prev => prev.filter(kb => kb.key !== key)) + } + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + setPressedKeys(prev => new Set(prev).add(event.key)) + + const currentKeys = Array.from(pressedKeys).concat(event.key).sort().join("+") + const keybind = keybinds.find(kb => { + const sortedKeybindKeys = kb.key.split("+").sort().join("+") + return sortedKeybindKeys === currentKeys + }) + + if (keybind) { + event.preventDefault() + keybind.callback() + } + } + + const handleKeyUp = (event: KeyboardEvent) => { + setPressedKeys(prev => { + const next = new Set(prev) + next.delete(event.key) + return next + }) + } + + window.addEventListener("keydown", handleKeyDown) + window.addEventListener("keyup", handleKeyUp) + return () => { + window.removeEventListener("keydown", handleKeyDown) + window.removeEventListener("keyup", handleKeyUp) + } + }, [keybinds, pressedKeys]) + + return {children} +} + +export const useKeybind = () => { + const context = useContext(KeybindContext) + if (context === undefined) { + throw new Error("useKeybind must be used within a KeybindProvider") + } + return context +}