diff --git a/apps/main/src/menu.ts b/apps/main/src/menu.ts index 7ecc66da5..1a73f46fe 100644 --- a/apps/main/src/menu.ts +++ b/apps/main/src/menu.ts @@ -59,7 +59,8 @@ export const registerAppMenu = () => { const mainWindow = getMainWindow() if (!mainWindow) return mainWindow.show() - dispatchEventOnWindow(mainWindow, "QuickAdd") + const caller = callWindowExpose(mainWindow) + caller.quickAdd() }, }, @@ -71,7 +72,9 @@ export const registerAppMenu = () => { const mainWindow = getMainWindow() if (!mainWindow) return mainWindow.show() - dispatchEventOnWindow(mainWindow, "Discover") + + const caller = callWindowExpose(mainWindow) + caller.goToDiscover() }, }, diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index 317952953..edd8b6d72 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -1,6 +1,6 @@ import { IN_ELECTRON } from "@follow/shared/constants" import { cn, getOS } from "@follow/utils/utils" -import { useEffect, useLayoutEffect } from "react" +import { useEffect } from "react" import { Outlet } from "react-router-dom" import { queryClient } from "~/lib/query-client" @@ -29,22 +29,6 @@ function App() { return cleanup }, []) - useLayoutEffect(() => { - // Electron app register in app scope, but web app should register in window scope - if (IN_ELECTRON) return - const handleOpenSettings = (e) => { - if (e.key === "," && (e.metaKey || e.ctrlKey)) { - window.router.showSettings() - e.preventDefault() - } - } - document.addEventListener("keydown", handleOpenSettings) - - return () => { - document.removeEventListener("keydown", handleOpenSettings) - } - }, []) - const windowsElectron = IN_ELECTRON && getOS() === "Windows" return ( diff --git a/apps/renderer/src/initialize/global-shortcuts.ts b/apps/renderer/src/initialize/global-shortcuts.ts new file mode 100644 index 000000000..27aef7784 --- /dev/null +++ b/apps/renderer/src/initialize/global-shortcuts.ts @@ -0,0 +1,80 @@ +import { callWindowExposeRenderer } from "@follow/shared/bridge" + +interface ShortcutDefinition { + accelerator: string + action: () => void +} + +const parseAccelerator = ( + accelerator: string, +): { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean } => { + const parts = accelerator.toLowerCase().split("+") + return { + key: parts.at(-1) ?? "", + ctrl: parts.includes("ctrl") || parts.includes("cmdorctrl"), + meta: parts.includes("cmd") || parts.includes("cmdorctrl"), + shift: parts.includes("shift"), + } +} + +export const registerAppGlobalShortcuts = () => { + // @see apps/main/menu.ts + const shortcuts: ShortcutDefinition[] = [ + { + accelerator: "CmdOrCtrl+,", + action: () => window.router.showSettings(), + }, + { + accelerator: "Ctrl+Shift+Z", + action: () => { + const caller = callWindowExposeRenderer() + caller.zenMode() + }, + }, + { + accelerator: "CmdOrCtrl+T", + action: () => { + const caller = callWindowExposeRenderer() + caller.goToDiscover() + }, + }, + { + accelerator: "CmdOrCtrl+N", + action: () => { + const caller = callWindowExposeRenderer() + caller.quickAdd() + }, + }, + ] + + const handleKeydown = (e: KeyboardEvent) => { + // Prevent on input, textarea, [contenteditable] + if ( + ["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName) || + (e.target as HTMLElement)?.contentEditable === "true" + ) { + return + } + + shortcuts.forEach(({ accelerator, action }) => { + const { key, ctrl, meta, shift } = parseAccelerator(accelerator) + + const matchesKey = e.key.toLowerCase() === key.toLowerCase() + const matchesModifier = ctrl + ? e.ctrlKey || e.metaKey + : !ctrl && !e.ctrlKey && !meta && !e.metaKey + const matchesShift = shift ? e.shiftKey : !e.shiftKey + + if (matchesKey && matchesModifier && matchesShift) { + action() + e.preventDefault() + } + }) + } + + document.addEventListener("keydown", handleKeydown, true) + + return () => { + document.removeEventListener("keydown", handleKeydown, true) + } +} diff --git a/apps/renderer/src/main.tsx b/apps/renderer/src/main.tsx index 227317e52..1315f223a 100644 --- a/apps/renderer/src/main.tsx +++ b/apps/renderer/src/main.tsx @@ -12,6 +12,7 @@ import { RouterProvider } from "react-router-dom" import { setAppIsReady } from "./atoms/app" import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "./constants" import { initializeApp } from "./initialize" +import { registerAppGlobalShortcuts } from "./initialize/global-shortcuts" import { router } from "./router" initializeApp().finally(() => { @@ -34,6 +35,8 @@ if (IN_ELECTRON) { } } document.documentElement.dataset.os = getOS() +} else { + registerAppGlobalShortcuts() } ReactDOM.createRoot($container).render( diff --git a/apps/renderer/src/modules/feed-column/index.tsx b/apps/renderer/src/modules/feed-column/index.tsx index 553ad94a3..5db305ce9 100644 --- a/apps/renderer/src/modules/feed-column/index.tsx +++ b/apps/renderer/src/modules/feed-column/index.tsx @@ -1,7 +1,7 @@ import { ActionButton } from "@follow/components/ui/button/index.js" import { Routes, views } from "@follow/constants" import { useTypeScriptHappyCallback } from "@follow/hooks" -import { useSubscribeElectronEvent } from "@follow/shared/event" +import { useRegisterGlobalContext } from "@follow/shared/bridge" import { stopPropagation } from "@follow/utils/dom" import { clamp, cn } from "@follow/utils/utils" import { useWheel } from "@use-gesture/react" @@ -136,7 +136,7 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam const showSidebarUnreadCount = useUISettingKey("sidebarShowUnreadCount") - useSubscribeElectronEvent("Discover", () => { + useRegisterGlobalContext("goToDiscover", () => { window.router.navigate(Routes.Discover) }) diff --git a/apps/renderer/src/modules/panel/cmdn.tsx b/apps/renderer/src/modules/panel/cmdn.tsx index 3b3de4e9a..477acbeb1 100644 --- a/apps/renderer/src/modules/panel/cmdn.tsx +++ b/apps/renderer/src/modules/panel/cmdn.tsx @@ -1,6 +1,6 @@ import { Form, FormControl, FormField, FormItem } from "@follow/components/ui/form/index.jsx" import type { FeedViewType } from "@follow/constants" -import { useSubscribeElectronEvent } from "@follow/shared/event" +import { useRegisterGlobalContext } from "@follow/shared/bridge" import { cn } from "@follow/utils/utils" import { zodResolver } from "@hookform/resolvers/zod" import { useLayoutEffect } from "react" @@ -115,7 +115,7 @@ export const CmdNTrigger = () => { }) } - useSubscribeElectronEvent("QuickAdd", handler) + useRegisterGlobalContext("quickAdd", handler) useHotkeys("meta+n,ctrl+n", handler, { scopes: HotKeyScopeMap.Home, diff --git a/packages/shared/src/bridge.ts b/packages/shared/src/bridge.ts index b598840c6..652a5e7f0 100644 --- a/packages/shared/src/bridge.ts +++ b/packages/shared/src/bridge.ts @@ -1,4 +1,5 @@ import type { BrowserWindow } from "electron" +import { useEffect, useLayoutEffect, useRef } from "react" import type { toast } from "sonner" import type { GeneralSettings, UISettings } from "./interface/settings" @@ -23,7 +24,10 @@ interface RenderGlobalContext { /// Actions follow: (options?: { isList: boolean; id?: string; url?: string }) => void profile: (id: string, variant?: "drawer" | "dialog") => void + quickAdd: () => void rsshubRoute: (route: string) => void + // Navigate + goToDiscover: () => void // user data clearIfLoginOtherAccount: (newUserId: string) => void @@ -45,6 +49,20 @@ export const registerGlobalContext = (context: Partial) => } } +export const useRegisterGlobalContext = ( + key: K, + fn: RenderGlobalContext[K], +) => { + const eventCallbackRef = useRef(fn) + useLayoutEffect(() => { + eventCallbackRef.current = fn + }, [fn]) + + useEffect(() => { + registerGlobalContext({ [key]: eventCallbackRef.current }) + }, [key]) +} + function createProxy(window: BrowserWindow, path: string[] = []): T { return new Proxy((() => {}) as any, { get(_, prop: string) { @@ -78,3 +96,7 @@ type Fn = { export function callWindowExpose(window: BrowserWindow) { return createProxy(window) as Fn } + +export function callWindowExposeRenderer() { + return globalThis[PREFIX] as Fn +}