Use platform-native app shortcuts on macOS (#521)

This commit is contained in:
Bradley Axen
2026-05-09 19:34:05 -07:00
committed by GitHub
parent e31101e79a
commit e902037913
8 changed files with 99 additions and 17 deletions

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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<string, KeyboardShortcutCommand> = {};
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({

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();