diff --git a/desktop/src/app/navigation/historyNavigationShortcuts.test.mjs b/desktop/src/app/navigation/historyNavigationShortcuts.test.mjs new file mode 100644 index 00000000000..0eab8dc3fa6 --- /dev/null +++ b/desktop/src/app/navigation/historyNavigationShortcuts.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { matchHistoryNavigationShortcut } from "./historyNavigationShortcuts.ts"; + +function probe(overrides) { + return { + key: "", + code: "", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...overrides, + }; +} + +test("mac back chord matches bracket key and code", () => { + assert.equal( + matchHistoryNavigationShortcut( + probe({ key: "[", code: "BracketLeft", metaKey: true }), + true, + ), + "back", + ); + assert.equal( + matchHistoryNavigationShortcut( + probe({ key: "Dead", code: "BracketLeft", metaKey: true }), + true, + ), + "back", + ); +}); + +test("mac forward chord matches bracket key and code", () => { + assert.equal( + matchHistoryNavigationShortcut( + probe({ key: "]", code: "BracketRight", metaKey: true }), + true, + ), + "forward", + ); +}); + +test("windows back and forward use alt arrows without modifiers", () => { + assert.equal( + matchHistoryNavigationShortcut( + probe({ key: "ArrowLeft", altKey: true }), + false, + ), + "back", + ); + assert.equal( + matchHistoryNavigationShortcut( + probe({ key: "ArrowRight", altKey: true }), + false, + ), + "forward", + ); +}); + +test("shift modifier blocks history chords", () => { + assert.equal( + matchHistoryNavigationShortcut( + probe({ key: "ArrowLeft", altKey: true, shiftKey: true }), + false, + ), + null, + ); +}); + +test("ctrl arrow left is not history navigation on windows", () => { + assert.equal( + matchHistoryNavigationShortcut( + probe({ key: "ArrowLeft", ctrlKey: true }), + false, + ), + null, + ); +}); diff --git a/desktop/src/app/navigation/historyNavigationShortcuts.ts b/desktop/src/app/navigation/historyNavigationShortcuts.ts new file mode 100644 index 00000000000..81a88546c62 --- /dev/null +++ b/desktop/src/app/navigation/historyNavigationShortcuts.ts @@ -0,0 +1,47 @@ +export type HistoryNavigationDirection = "back" | "forward"; + +export type HistoryShortcutProbe = { + key: string; + code: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; +}; + +/** + * Match global back/forward history chords. These intentionally bypass the + * editable-target guard — they have no text-editing semantics in composers. + */ +export function matchHistoryNavigationShortcut( + probe: HistoryShortcutProbe, + isMac: boolean, +): HistoryNavigationDirection | null { + if (probe.shiftKey) { + return null; + } + + if (isMac) { + if (!probe.metaKey || probe.ctrlKey || probe.altKey) { + return null; + } + if (probe.key === "[" || probe.code === "BracketLeft") { + return "back"; + } + if (probe.key === "]" || probe.code === "BracketRight") { + return "forward"; + } + return null; + } + + if (!probe.altKey || probe.metaKey || probe.ctrlKey) { + return null; + } + if (probe.key === "ArrowLeft") { + return "back"; + } + if (probe.key === "ArrowRight") { + return "forward"; + } + return null; +} diff --git a/desktop/src/app/navigation/useBackForwardControls.ts b/desktop/src/app/navigation/useBackForwardControls.ts index 7f7d84f6d72..59034c444a4 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 { matchHistoryNavigationShortcut } from "@/app/navigation/historyNavigationShortcuts"; import { isMacPlatform } from "@/shared/lib/platform"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; @@ -14,19 +15,6 @@ type RouterHistoryState = { key?: string; }; -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - - return ( - target.isContentEditable || - target.closest( - 'input, textarea, select, [contenteditable=""], [contenteditable="true"]', - ) !== null - ); -} - export function useBackForwardControls() { const router = useRouter(); const canGoBack = useCanGoBack(); @@ -81,41 +69,25 @@ export function useBackForwardControls() { }, [canGoForward, router.history]); const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => { - if (isEditableTarget(event.target)) { - return; - } - - const isMac = isMacPlatform(); - const isBackShortcut = isMac - ? event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - (event.key === "[" || event.code === "BracketLeft") - : event.altKey && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - event.key === "ArrowLeft"; - const isForwardShortcut = isMac - ? event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - (event.key === "]" || event.code === "BracketRight") - : event.altKey && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - event.key === "ArrowRight"; - - if (isBackShortcut) { + const direction = matchHistoryNavigationShortcut( + { + key: event.key, + code: event.code, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + }, + isMacPlatform(), + ); + + if (direction === "back") { event.preventDefault(); goBack(); return; } - if (isForwardShortcut) { + if (direction === "forward") { event.preventDefault(); goForward(); } diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index f7a96cd568b..e119bab4618 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -48,6 +48,31 @@ test("global back and forward move across channel routes", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("random"); }); +test("back and forward keyboard chords work while the composer is focused", async ({ + page, +}) => { + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + const composer = page.getByTestId("message-composer"); + await composer.click(); + await expect(composer).toBeFocused(); + + await page.keyboard.press(process.platform === "darwin" ? "Meta+[" : "Alt+ArrowLeft"); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await composer.click(); + await page.keyboard.press( + process.platform === "darwin" ? "Meta+]" : "Alt+ArrowRight", + ); + await expect(page.getByTestId("chat-title")).toHaveText("random"); +}); + // FIXME: the forum post "Back to posts" header renders under the fixed top // chrome drag region, which intercepts the click. Pre-existing breakage — // this spec file was never registered in playwright.config.ts until now.