diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 40296a9cc..344bc52cc 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -52,6 +52,7 @@ import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; import { joinChannel } from "@/shared/api/tauri"; import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; +import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { Button } from "@/shared/ui/button"; import { SidebarInset, @@ -398,7 +399,7 @@ export function AppShell() { } function handleKeyDown(event: KeyboardEvent) { - if (!(event.metaKey || event.ctrlKey) || event.altKey) { + if (!hasPrimaryShortcutModifier(event) || event.altKey) { return; } @@ -444,7 +445,7 @@ export function AppShell() { function handleKeyDown(event: KeyboardEvent) { const isSettingsShortcut = (event.key === "," || event.code === "Comma") && - (event.metaKey || event.ctrlKey) && + hasPrimaryShortcutModifier(event) && !event.altKey && !event.shiftKey; diff --git a/desktop/src/app/navigation/useBackForwardControls.ts b/desktop/src/app/navigation/useBackForwardControls.ts index 56e7874a6..7f7d84f6d 100644 --- a/desktop/src/app/navigation/useBackForwardControls.ts +++ b/desktop/src/app/navigation/useBackForwardControls.ts @@ -5,6 +5,7 @@ import { useRouterState, } from "@tanstack/react-router"; +import { isMacPlatform } from "@/shared/lib/platform"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; type RouterHistoryState = { @@ -26,10 +27,6 @@ function isEditableTarget(target: EventTarget | null): boolean { ); } -function isMacPlatform() { - return window.navigator.platform.toLowerCase().includes("mac"); -} - export function useBackForwardControls() { const router = useRouter(); const canGoBack = useCanGoBack(); diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index 182927742..162062f0d 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -1,6 +1,8 @@ import * as React from "react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; + const DEFAULT_ZOOM_FACTOR = 1; const MIN_ZOOM_FACTOR = 0.2; const MAX_ZOOM_FACTOR = 10; @@ -9,7 +11,7 @@ const ZOOM_STEP = 0.2; type ZoomAction = "increase" | "decrease" | "reset"; function getZoomAction(event: KeyboardEvent): ZoomAction | null { - if (!(event.metaKey || event.ctrlKey) || event.altKey) { + if (!hasPrimaryShortcutModifier(event) || event.altKey) { return null; } diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 6b2ae945b..16fc4f843 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -5,8 +5,10 @@ import { useEditor, type Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import Link from "@tiptap/extension-link"; -import { Extension } from "@tiptap/core"; -import { TextSelection } from "@tiptap/pm/state"; +import { Extension, type KeyboardShortcutCommand } from "@tiptap/core"; +import { Selection, TextSelection } from "@tiptap/pm/state"; + +import { isMacPlatform } from "@/shared/lib/platform"; import { MentionHighlightExtension, @@ -73,6 +75,55 @@ export function useRichTextEditor({ // below with custom options (autolink, openOnClick, etc.). link: false, }), + // macOS text fields traditionally support a small set of Emacs-style + // Control shortcuts. ProseMirror already handles Ctrl-A/E/H/D on macOS; + // these fill in the common movement and kill-line gaps for the composer. + Extension.create({ + name: "macEmacsTextShortcuts", + addKeyboardShortcuts() { + const shortcuts: Record = {}; + if (!isMacPlatform()) { + return shortcuts; + } + + return { + "Ctrl-b": ({ editor: ed }) => { + const { empty, from } = ed.state.selection; + if (!empty || from <= 0) return false; + return ed.commands.setTextSelection(from - 1); + }, + "Ctrl-f": ({ editor: ed }) => { + const { empty, from } = ed.state.selection; + if (!empty || from >= ed.state.doc.content.size) return false; + return ed.commands.setTextSelection(from + 1); + }, + "Ctrl-k": ({ editor: ed }) => { + const { state, view } = ed; + const { $from, empty, from, to } = state.selection; + + if (!empty) { + return ed.commands.deleteSelection(); + } + + const blockEnd = $from.end(); + if (from < blockEnd) { + return ed.commands.deleteRange({ from, to: blockEnd }); + } + + const nextSelection = Selection.findFrom( + state.doc.resolve(to), + 1, + true, + ); + if (!nextSelection) return false; + + const transaction = state.tr.delete(to, nextSelection.from); + view.dispatch(transaction.scrollIntoView()); + return true; + }, + }; + }, + }), // Shift+Enter inside lists/blockquotes: split the node instead of // inserting a hard break so continuation lines keep their formatting. Extension.create({ diff --git a/desktop/src/features/search/useChannelFind.ts b/desktop/src/features/search/useChannelFind.ts index c31ea4f61..7d992c101 100644 --- a/desktop/src/features/search/useChannelFind.ts +++ b/desktop/src/features/search/useChannelFind.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { useSearchMessagesQuery } from "@/features/search/hooks"; import type { TimelineMessage } from "@/features/messages/types"; +import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; const MIN_QUERY_LENGTH = 2; const DEBOUNCE_MS = 300; @@ -119,11 +120,11 @@ export function useChannelFind({ channelId, messages }: UseChannelFindOptions) { ); }, [matchedIds.length]); - // Register CMD+F keyboard shortcut. + // Register platform-standard find shortcut (⌘F on macOS, Ctrl+F elsewhere). React.useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if ( - (event.metaKey || event.ctrlKey) && + hasPrimaryShortcutModifier(event) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "f" diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index d5cf1db24..0fb2f2043 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -1,3 +1,5 @@ +import { isMacPlatform } from "@/shared/lib/platform"; + export type ShortcutCategory = | "Navigation" | "Messages" @@ -220,10 +222,6 @@ export function getShortcutsByCategory(): Map< return map; } -function isMac(): boolean { - return /mac|iphone|ipad|ipod/i.test(navigator.platform); -} - export function getPlatformKeys(shortcut: KeyboardShortcut): string { - return isMac() ? shortcut.keys : shortcut.keysWindows; + return isMacPlatform() ? shortcut.keys : shortcut.keysWindows; } diff --git a/desktop/src/shared/lib/platform.ts b/desktop/src/shared/lib/platform.ts new file mode 100644 index 000000000..759cd9818 --- /dev/null +++ b/desktop/src/shared/lib/platform.ts @@ -0,0 +1,31 @@ +type ModifierKeyboardEvent = Pick< + KeyboardEvent, + "altKey" | "ctrlKey" | "metaKey" | "shiftKey" +>; + +/** Returns true on macOS/iOS-style Apple platforms. */ +export function isMacPlatform(): boolean { + if (typeof navigator === "undefined") { + return false; + } + + return /mac|iphone|ipad|ipod/i.test(navigator.platform); +} + +/** + * The platform's normal application-shortcut modifier: + * - macOS: Command (Meta) + * - Windows/Linux: Control + * + * On macOS this intentionally rejects Control so native Emacs-style text + * editing shortcuts (Ctrl-A/E/B/F/K/etc.) are left available to text fields. + */ +export function hasPrimaryShortcutModifier( + event: ModifierKeyboardEvent, +): boolean { + if (isMacPlatform()) { + return event.metaKey && !event.ctrlKey; + } + + return event.ctrlKey && !event.metaKey; +} diff --git a/desktop/src/shared/ui/sidebar.tsx b/desktop/src/shared/ui/sidebar.tsx index 977167505..a40fe2868 100644 --- a/desktop/src/shared/ui/sidebar.tsx +++ b/desktop/src/shared/ui/sidebar.tsx @@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; import { cn } from "@/shared/lib/cn"; +import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useIsMobile } from "@/shared/hooks/use-mobile"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; @@ -105,7 +106,7 @@ const SidebarProvider = React.forwardRef< const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) + hasPrimaryShortcutModifier(event) ) { event.preventDefault(); toggleSidebar();