From d5d1fc2a364beba27344b055f931e6070776b678 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma <237243628+Dhruv-Sharma29@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:13:55 +0530 Subject: [PATCH] feat(shortcuts): expand keyboard shortcuts and group the Shortcuts menu - Add working shortcuts for Open Folder, Strikethrough, Inline Code, Insert Link, Toggle Focus Mode, Toggle Preview Panel, font size up and down, and next/previous tab. - Add the new commands to the Format and View menus, and rename "Show Preview Panel" to "Toggle Preview Panel" to match what it does. - Move the shortcut table into shortcut-definitions.mjs. The key handler and the Shortcuts menu still read the same table; plain JS lets the Node 20 test suite check the real bindings. - Group the Shortcuts menu by category with separators. - Test both platforms for duplicate chords, reserved system/clipboard chords, missing labels, and split categories. - Update docs/KeyboardShortcuts.md. Closes #458 --- docs/KeyboardShortcuts.md | 14 ++- frontend/src/app/page.tsx | 19 ++- frontend/src/components/Toolbar.tsx | 4 +- frontend/src/hooks/editor/useActions.ts | 47 +++++++- .../src/hooks/editor/useEditorCommands.ts | 6 + frontend/src/lib/keyboardShortcuts.ts | 108 ++--------------- frontend/src/lib/shortcut-definitions.mjs | 111 ++++++++++++++++++ frontend/tests/shortcut-definitions.test.mjs | 58 +++++++++ 8 files changed, 257 insertions(+), 110 deletions(-) create mode 100644 frontend/src/lib/shortcut-definitions.mjs create mode 100644 frontend/tests/shortcut-definitions.test.mjs diff --git a/docs/KeyboardShortcuts.md b/docs/KeyboardShortcuts.md index faf4eedc..f7bce30b 100644 --- a/docs/KeyboardShortcuts.md +++ b/docs/KeyboardShortcuts.md @@ -8,9 +8,12 @@ This page lists the default shortcuts defined by the application. The primary mo | --- | --- | --- | | New file | Command+N | Ctrl+N | | Open file | Command+O | Ctrl+O | +| Open folder | Command+Shift+O | Ctrl+Shift+O | | Save file | Command+S | Ctrl+S | | Close current tab | Command+W | Ctrl+W | | Close all tabs | Command+Shift+W | Ctrl+Shift+W | +| Next tab | Control+Tab | Ctrl+Tab | +| Previous tab | Control+Shift+Tab | Ctrl+Shift+Tab | ## Editing and formatting @@ -23,6 +26,9 @@ This page lists the default shortcuts defined by the application. The primary mo | Bold | Command+B | Ctrl+B | | Italic | Command+I | Ctrl+I | | Underline | Command+U | Ctrl+U | +| Strikethrough | Command+Shift+X | Ctrl+Shift+X | +| Inline code | Command+E | Ctrl+E | +| Insert link | Command+K | Ctrl+K | | Heading 1 | Command+1 | Ctrl+1 | | Heading 2 | Command+2 | Ctrl+2 | | Heading 3 | Command+3 | Ctrl+3 | @@ -37,13 +43,19 @@ This page lists the default shortcuts defined by the application. The primary mo | Export to Word (DOCX) | Command+Option+D | Ctrl+Alt+D | | Export to PDF | Command+Option+P | Ctrl+Alt+P | | Toggle dark mode | F6 | F6 | +| Toggle Focus Mode | F8 | F8 | +| Toggle preview panel | Command+Shift+V | Ctrl+Shift+V | | Toggle AI panel | Command+Shift+A | Ctrl+Shift+A | | Open preview in browser | Command+Option+B | Ctrl+Alt+B | +| Increase font size | Command+= | Ctrl+= | +| Decrease font size | Command+- | Ctrl+- | | Full-width editor | Command+Option+Left Arrow | Ctrl+Alt+Left Arrow | | Balanced split view | Command+Option+Down Arrow | Ctrl+Alt+Down Arrow | | Full-width preview | Command+Option+Right Arrow | Ctrl+Alt+Right Arrow | -The search, replace, formatting, and table shortcuts are tied to the main Monaco editor. Focus Mode uses a different rich editor and has its own slash-command menu; use the toolbar and Focus Mode's editor controls for actions that are not handled by Monaco. File-level and view-level shortcuts are application commands. +The search, replace, formatting, and table shortcuts are tied to the main Monaco editor. Focus Mode uses a different rich editor and has its own slash-command menu; use the toolbar and Focus Mode's editor controls for actions that are not handled by Monaco. File-level and view-level shortcuts are application commands. The preview-panel shortcut is editor-scoped too, so Ctrl+Shift+V still pastes inside ordinary text fields. + +Every shortcut is defined once, in `frontend/src/lib/shortcut-definitions.mjs`. The keyboard handler and the **Shortcuts** menu both read that table, and the frontend tests check it for duplicate or reserved key chords on each platform. Shortcut execution respects editor focus. For example, an editor-scoped shortcut should not intercept native undo or typing while focus is in an ordinary text input such as the AI prompt field. diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ed846b0e..4bcdaf5a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -68,6 +68,16 @@ export default function HomePage() { setSplit(showAI ? 60 : 50); }, [activePanel, setShowAIPanel]); + const handleTabSelect = useCallback((id: string) => { + editor.setActiveTabId(id); + const tab = editor.tabs.find((item) => item.id === id); + if (tab) editor.refreshPreview(tab.content, tab.filePath ?? undefined); + }, [editor]); + const toggleFocusMode = useCallback(() => { + setFocusMode((enabled) => !enabled); + resetEditor(); + }, [resetEditor]); + const { formatting, menuGroups, @@ -90,14 +100,11 @@ export default function HomePage() { onOpenProject: () => { void openProject(); }, onFileSaved: () => setProjectRefreshToken((token) => token + 1), onShowPreview: () => selectPanel(PANELS.PREVIEW), + onToggleFocusMode: toggleFocusMode, + onSelectTab: handleTabSelect, executeAIPrompt, syncSelectedText, }); - const handleTabSelect = useCallback((id: string) => { - editor.setActiveTabId(id); - const tab = editor.tabs.find((item) => item.id === id); - if (tab) editor.refreshPreview(tab.content, tab.filePath ?? undefined); - }, [editor]); const handlePublicationExport = useCallback(async (content: string, format: "html" | "pdf") => { const extension = format === "html" ? "html" : "pdf"; const payload = { @@ -139,7 +146,7 @@ export default function HomePage() { onOpenProject={() => { void openProject(); }} onExport={fileIO.handleExport} onOpenBrowserPreview={() => { void handleOpenBrowserPreview(); }} onToggleDark={() => editor.setDarkMode((dark) => !dark)} activePanel={activePanel} onTogglePanel={selectPanel} - onToggleFocusMode={() => { setFocusMode((enabled) => !enabled); resetEditor(); }} + onToggleFocusMode={toggleFocusMode} darkMode={editor.darkMode} fontSize={editor.fontSize} onFontSizeChange={editor.setFontSize} showFocusPanel={focusMode} backendStatus={showPackagedBackendStatus ? backendStatus : "ready"} backendMessage={showPackagedBackendStatus ? backendMessage : null} diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 29c1e57a..98145f5e 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -14,8 +14,8 @@ import type { import { PANELS, type PanelId } from "@/types/panels"; -const MIN_FONT_SIZE = 8; -const MAX_FONT_SIZE = 72; +export const MIN_FONT_SIZE = 8; +export const MAX_FONT_SIZE = 72; type Props = { onOpenFile: () => void; diff --git a/frontend/src/hooks/editor/useActions.ts b/frontend/src/hooks/editor/useActions.ts index 34c68e8e..1bc709c4 100644 --- a/frontend/src/hooks/editor/useActions.ts +++ b/frontend/src/hooks/editor/useActions.ts @@ -7,6 +7,7 @@ import type { useEditor } from "@/hooks/useEditor"; import type { useFileIO } from "@/hooks/useFileIO"; import type { EditorFormattingController } from "@/hooks/editor/useEditorFormatting"; import type { MenuGroup } from "@/components/MenuBar"; +import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/components/Toolbar"; import type { FocusEditorHandle } from "@/components/FocusModePane"; import { Markdown } from "@/lib/api"; import { @@ -35,6 +36,9 @@ export type UseActionsOptions = { onOpenProject: () => void; onFileSaved: () => void; onShowPreview: () => void; + onToggleFocusMode: () => void; + /** Activate a tab and refresh the preview for it. */ + onSelectTab: (id: string) => void; }; export function useActions({ @@ -49,6 +53,8 @@ export function useActions({ onOpenProject, onFileSaved, onShowPreview, + onToggleFocusMode, + onSelectTab, }: UseActionsOptions) { const { handleOpenFile, handleExport } = fileIO; const { @@ -81,6 +87,13 @@ export function useActions({ } }, [backendStatus, editor.activeTab.content, editor.activeTab.filePath, editor.darkMode, editor.fontSize, monacoRef]); + const selectAdjacentTab = useCallback((step: 1 | -1) => { + const { tabs, activeTabId } = editor; + if (tabs.length < 2) return; + const index = tabs.findIndex((tab) => tab.id === activeTabId); + onSelectTab(tabs[(index + step + tabs.length) % tabs.length].id); + }, [editor, onSelectTab]); + const actions = useMemo void>>( () => ({ "file.new": editor.newTab, @@ -117,6 +130,9 @@ export function useActions({ "format.heading2": () => applyHeading(2), "format.heading3": () => applyHeading(3), "format.normal": () => applyHeading(0), + "format.strikethrough": () => wrapSelection("strikethrough", "~~"), + "format.inlineCode": () => wrapSelection("inline-code", "`"), + "format.link": () => wrapSelection("link", "[", "](url)"), "table.insert": insertTable, "view.toggleDarkMode": () => editor.setDarkMode((dark) => !dark), "view.toggleAIPanel": () => setShowAIPanel((visible) => !visible), @@ -125,6 +141,11 @@ export function useActions({ "view.fullEditor": () => setSplit(100), "view.balancedSplit": () => setSplit(50), "view.fullPreview": () => setSplit(0), + "view.toggleFocusMode": onToggleFocusMode, + "view.increaseFontSize": () => editor.setFontSize((size) => Math.min(MAX_FONT_SIZE, size + 1)), + "view.decreaseFontSize": () => editor.setFontSize((size) => Math.max(MIN_FONT_SIZE, size - 1)), + "tab.next": () => selectAdjacentTab(1), + "tab.previous": () => selectAdjacentTab(-1), }), [ applyHeading, @@ -140,6 +161,8 @@ export function useActions({ setSplit, onOpenProject, onShowPreview, + onToggleFocusMode, + selectAdjacentTab, wrapSelection, ] ); @@ -181,6 +204,9 @@ export function useActions({ { id: "format.bold", label: "Bold", onSelect: actions["format.bold"] }, { id: "format.italic", label: "Italic", onSelect: actions["format.italic"] }, { id: "format.underline", label: "Underline", onSelect: actions["format.underline"] }, + { id: "format.strikethrough", label: "Strikethrough", onSelect: actions["format.strikethrough"] }, + { id: "format.inlineCode", label: "Inline Code", onSelect: actions["format.inlineCode"] }, + { id: "format.link", label: "Insert Link", onSelect: actions["format.link"] }, "separator", { id: "format.heading1", label: "Heading 1", onSelect: actions["format.heading1"] }, { id: "format.heading2", label: "Heading 2", onSelect: actions["format.heading2"] }, @@ -192,13 +218,20 @@ export function useActions({ label: "View", items: [ { id: "view.toggleDarkMode", label: "Toggle Dark Mode", onSelect: actions["view.toggleDarkMode"] }, + { id: "view.toggleFocusMode", label: "Toggle Focus Mode", onSelect: actions["view.toggleFocusMode"] }, { id: "view.toggleAIPanel", label: "Show AI Agent Panel", onSelect: actions["view.toggleAIPanel"] }, - { id: "view.showPreview", label: "Show Preview Panel", onSelect: actions["view.showPreview"] }, + { id: "view.showPreview", label: "Toggle Preview Panel", onSelect: actions["view.showPreview"] }, { id: "view.openBrowserPreview", label: "Open Preview in Browser", onSelect: actions["view.openBrowserPreview"], disabled: backendDisabled }, "separator", { id: "view.fullEditor", label: "Full Width Editor", onSelect: actions["view.fullEditor"] }, { id: "view.balancedSplit", label: "Balanced Split View", onSelect: actions["view.balancedSplit"] }, { id: "view.fullPreview", label: "Full Width Preview", onSelect: actions["view.fullPreview"] }, + "separator", + { id: "view.increaseFontSize", label: "Increase Font Size", onSelect: actions["view.increaseFontSize"] }, + { id: "view.decreaseFontSize", label: "Decrease Font Size", onSelect: actions["view.decreaseFontSize"] }, + "separator", + { id: "tab.next", label: "Next Tab", onSelect: actions["tab.next"] }, + { id: "tab.previous", label: "Previous Tab", onSelect: actions["tab.previous"] }, ], }, { @@ -207,11 +240,13 @@ export function useActions({ }, { label: "Shortcuts", - items: shortcuts.map((shortcut) => ({ - id: shortcut.id, - label: shortcut.label, - onSelect: actions[shortcut.id], - })), + // Same table as the key handler, split into categories by id prefix. + items: shortcuts.flatMap((shortcut, index): MenuGroup["items"] => { + const item = { id: shortcut.id, label: shortcut.label, onSelect: actions[shortcut.id] }; + const category = shortcut.id.split(".")[0]; + const startsCategory = index > 0 && shortcuts[index - 1].id.split(".")[0] !== category; + return startsCategory ? ["separator", item] : [item]; + }), }, ], [actions, backendDisabled, shortcuts] diff --git a/frontend/src/hooks/editor/useEditorCommands.ts b/frontend/src/hooks/editor/useEditorCommands.ts index 51793bec..4136fedb 100644 --- a/frontend/src/hooks/editor/useEditorCommands.ts +++ b/frontend/src/hooks/editor/useEditorCommands.ts @@ -26,6 +26,8 @@ export type UseEditorCommandsOptions = { onOpenProject: () => void; onFileSaved: () => void; onShowPreview: () => void; + onToggleFocusMode: () => void; + onSelectTab: (id: string) => void; executeAIPrompt: (prompt: string, documentText: string) => Promise; syncSelectedText: () => void; }; @@ -47,6 +49,8 @@ export function useEditorCommands({ onOpenProject, onFileSaved, onShowPreview, + onToggleFocusMode, + onSelectTab, executeAIPrompt, syncSelectedText, }: UseEditorCommandsOptions) { @@ -66,6 +70,8 @@ export function useEditorCommands({ onOpenProject, onFileSaved, onShowPreview, + onToggleFocusMode, + onSelectTab, }); // 3. Register keyboard shortcuts diff --git a/frontend/src/lib/keyboardShortcuts.ts b/frontend/src/lib/keyboardShortcuts.ts index f2337d8e..dd094714 100644 --- a/frontend/src/lib/keyboardShortcuts.ts +++ b/frontend/src/lib/keyboardShortcuts.ts @@ -1,5 +1,7 @@ "use client"; +import { buildDefaultShortcuts } from "@/lib/shortcut-definitions.mjs"; + export type ActionId = | "file.new" | "file.open" @@ -21,6 +23,9 @@ export type ActionId = | "format.heading2" | "format.heading3" | "format.normal" + | "format.strikethrough" + | "format.inlineCode" + | "format.link" | "table.insert" | "view.toggleDarkMode" | "view.toggleAIPanel" @@ -28,7 +33,12 @@ export type ActionId = | "view.openBrowserPreview" | "view.fullEditor" | "view.balancedSplit" - | "view.fullPreview"; + | "view.fullPreview" + | "view.toggleFocusMode" + | "view.increaseFontSize" + | "view.decreaseFontSize" + | "tab.next" + | "tab.previous"; export type ShortcutBinding = { key: string; @@ -52,100 +62,8 @@ const isMac = typeof navigator !== "undefined" && (/Mac|iPhone|iPad|iPod/i.test(navigator.platform) || /Mac/i.test(navigator.userAgent)); -function keyCodeFor(key: string) { - if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`; - if (/^\d$/.test(key)) return `Digit${key}`; - return undefined; -} - -const primary = (key: string, extra: Omit = {}): ShortcutBinding => ({ - key, - code: keyCodeFor(key), - ...(isMac ? { meta: true } : { ctrl: true }), - ...extra, -}); - -const primaryAlt = (key: string): ShortcutBinding => - isMac - ? { key, code: keyCodeFor(key), meta: true, alt: true } - : { key, code: keyCodeFor(key), ctrl: true, alt: true }; - -export const DEFAULT_SHORTCUTS: ShortcutDefinition[] = [ - { id: "file.new", label: "New File", scope: "global", bindings: [primary("n")] }, - { id: "file.open", label: "Open File", scope: "global", bindings: [primary("o")] }, - { id: "file.save", label: "Save File", scope: "global", bindings: [primary("s")] }, - { id: "file.closeTab", label: "Close Current Tab", scope: "global", bindings: [primary("w")] }, - { - id: "file.closeAllTabs", - label: "Close All Tabs", - scope: "global", - bindings: [primary("w", { shift: true })], - }, - // Undo/redo drive the document editor (useActions maps them to - // runMonacoAction), so they are scoped to the editor like every other - // `edit.*` / `format.*` command. With `scope: "global"` they also fired while - // typing in the AI panel's chat and work-instruction textareas and in the - // settings inputs: preventDefault killed the field's native undo, then focus - // jumped to Monaco and undid a document edit instead. - { id: "edit.undo", label: "Undo", scope: "editor", bindings: [primary("z")] }, - { - id: "edit.redo", - label: "Redo", - scope: "editor", - bindings: isMac - ? [primary("z", { shift: true })] - : [{ key: "y", ctrl: true }, primary("z", { shift: true })], - }, - { id: "edit.search", label: "Search", scope: "editor", bindings: [primary("f")] }, - { - id: "edit.replace", - label: "Replace", - scope: "editor", - bindings: [isMac ? primaryAlt("f") : { key: "h", ctrl: true }], - }, - { id: "format.bold", label: "Bold", scope: "editor", bindings: [primary("b")] }, - { id: "format.italic", label: "Italic", scope: "editor", bindings: [primary("i")] }, - { id: "format.underline", label: "Underline", scope: "editor", bindings: [primary("u")] }, - { id: "format.heading1", label: "Heading 1", scope: "editor", bindings: [primary("1")] }, - { id: "format.heading2", label: "Heading 2", scope: "editor", bindings: [primary("2")] }, - { id: "format.heading3", label: "Heading 3", scope: "editor", bindings: [primary("3")] }, - { id: "format.normal", label: "Normal Text", scope: "editor", bindings: [primary("0")] }, - { id: "table.insert", label: "Insert Table", scope: "editor", bindings: [primaryAlt("t")] }, - { id: "file.exportHtml", label: "Export to HTML", scope: "editor", bindings: [primaryAlt("h")] }, - { id: "file.exportDocx", label: "Export to Word", scope: "editor", bindings: [primaryAlt("d")] }, - { id: "file.exportPdf", label: "Export to PDF", scope: "editor", bindings: [primaryAlt("p")] }, - { id: "view.toggleDarkMode", label: "Toggle Dark Mode", scope: "global", bindings: [{ key: "F6" }] }, - { - id: "view.toggleAIPanel", - label: "Toggle AI Agent Panel", - scope: "global", - bindings: [primary("a", { shift: true })], - }, - { - id: "view.openBrowserPreview", - label: "Open Preview in Browser", - scope: "global", - bindings: [primaryAlt("b")], - }, - { - id: "view.fullEditor", - label: "Full Width Editor", - scope: "global", - bindings: [primaryAlt("ArrowLeft")], - }, - { - id: "view.balancedSplit", - label: "Balanced Split View", - scope: "global", - bindings: [primaryAlt("ArrowDown")], - }, - { - id: "view.fullPreview", - label: "Full Width Preview", - scope: "global", - bindings: [primaryAlt("ArrowRight")], - }, -]; +// The table itself lives in shortcut-definitions.mjs so tests can check it. +export const DEFAULT_SHORTCUTS = buildDefaultShortcuts(isMac) as ShortcutDefinition[]; export function resolveShortcutDefinitions( overrides: ShortcutOverrideMap = {} diff --git a/frontend/src/lib/shortcut-definitions.mjs b/frontend/src/lib/shortcut-definitions.mjs new file mode 100644 index 00000000..3114706e --- /dev/null +++ b/frontend/src/lib/shortcut-definitions.mjs @@ -0,0 +1,111 @@ +/** + * The single source of truth for keyboard shortcuts. + * + * Both the global keydown handler (useKeyboardShortcuts) and the Shortcuts menu + * read this table, so a binding cannot work without being listed or be listed + * without working. It is plain JavaScript so the frontend test suite (Node 20, + * no TypeScript loader) can check the real table for conflicts on every + * platform. + * + * @typedef {{ key: string, code?: string, ctrl?: boolean, meta?: boolean, shift?: boolean, alt?: boolean }} Binding + * @typedef {{ id: string, label: string, scope: "global" | "editor", bindings: Binding[] }} Definition + */ + +const NAMED_KEY_CODES = { "=": "Equal", "-": "Minus" }; + +/** Physical key code, so a chord still matches when a layout changes `key`. */ +export function keyCodeFor(key) { + if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`; + if (/^\d$/.test(key)) return `Digit${key}`; + return NAMED_KEY_CODES[key]; +} + +/** + * Build the shortcut table for one platform. + * + * Shortcuts are listed category by category (file, edit, format, table, view, + * tab); the Shortcuts menu separates them where the id prefix changes. + * + * @param {boolean} isMac + * @returns {Definition[]} + */ +export function buildDefaultShortcuts(isMac) { + /** Cmd on macOS, Ctrl elsewhere. */ + const primary = (key, extra = {}) => ({ + key, + code: keyCodeFor(key), + ...(isMac ? { meta: true } : { ctrl: true }), + ...extra, + }); + const primaryAlt = (key) => primary(key, { alt: true }); + + return [ + { id: "file.new", label: "New File", scope: "global", bindings: [primary("n")] }, + { id: "file.open", label: "Open File", scope: "global", bindings: [primary("o")] }, + { id: "file.openFolder", label: "Open Folder", scope: "global", bindings: [primary("o", { shift: true })] }, + { id: "file.save", label: "Save File", scope: "global", bindings: [primary("s")] }, + { id: "file.exportHtml", label: "Export to HTML", scope: "editor", bindings: [primaryAlt("h")] }, + { id: "file.exportDocx", label: "Export to Word", scope: "editor", bindings: [primaryAlt("d")] }, + { id: "file.exportPdf", label: "Export to PDF", scope: "editor", bindings: [primaryAlt("p")] }, + { id: "file.closeTab", label: "Close Current Tab", scope: "global", bindings: [primary("w")] }, + { id: "file.closeAllTabs", label: "Close All Tabs", scope: "global", bindings: [primary("w", { shift: true })] }, + + // Undo/redo drive the document editor (useActions maps them to + // runMonacoAction), so they are scoped to the editor like every other + // `edit.*` / `format.*` command. With `scope: "global"` they also fired while + // typing in the AI panel's chat and work-instruction textareas and in the + // settings inputs: preventDefault killed the field's native undo, then focus + // jumped to Monaco and undid a document edit instead. + { id: "edit.undo", label: "Undo", scope: "editor", bindings: [primary("z")] }, + { + id: "edit.redo", + label: "Redo", + scope: "editor", + bindings: isMac + ? [primary("z", { shift: true })] + : [{ key: "y", ctrl: true }, primary("z", { shift: true })], + }, + { id: "edit.search", label: "Search", scope: "editor", bindings: [primary("f")] }, + { + id: "edit.replace", + label: "Replace", + scope: "editor", + bindings: [isMac ? primaryAlt("f") : { key: "h", ctrl: true }], + }, + + { id: "format.bold", label: "Bold", scope: "editor", bindings: [primary("b")] }, + { id: "format.italic", label: "Italic", scope: "editor", bindings: [primary("i")] }, + { id: "format.underline", label: "Underline", scope: "editor", bindings: [primary("u")] }, + { id: "format.strikethrough", label: "Strikethrough", scope: "editor", bindings: [primary("x", { shift: true })] }, + { id: "format.inlineCode", label: "Inline Code", scope: "editor", bindings: [primary("e")] }, + { id: "format.link", label: "Insert Link", scope: "editor", bindings: [primary("k")] }, + { id: "format.heading1", label: "Heading 1", scope: "editor", bindings: [primary("1")] }, + { id: "format.heading2", label: "Heading 2", scope: "editor", bindings: [primary("2")] }, + { id: "format.heading3", label: "Heading 3", scope: "editor", bindings: [primary("3")] }, + { id: "format.normal", label: "Normal Text", scope: "editor", bindings: [primary("0")] }, + + { id: "table.insert", label: "Insert Table", scope: "editor", bindings: [primaryAlt("t")] }, + + { id: "view.toggleDarkMode", label: "Toggle Dark Mode", scope: "global", bindings: [{ key: "F6" }] }, + { id: "view.toggleFocusMode", label: "Toggle Focus Mode", scope: "global", bindings: [{ key: "F8" }] }, + // Editor scope: in the app's own text fields Ctrl+Shift+V stays "paste". + { id: "view.showPreview", label: "Toggle Preview Panel", scope: "editor", bindings: [primary("v", { shift: true })] }, + { id: "view.toggleAIPanel", label: "Toggle AI Agent Panel", scope: "global", bindings: [primary("a", { shift: true })] }, + { id: "view.openBrowserPreview", label: "Open Preview in Browser", scope: "global", bindings: [primaryAlt("b")] }, + { id: "view.increaseFontSize", label: "Increase Font Size", scope: "global", bindings: [primary("=")] }, + { id: "view.decreaseFontSize", label: "Decrease Font Size", scope: "global", bindings: [primary("-")] }, + { id: "view.fullEditor", label: "Full Width Editor", scope: "global", bindings: [primaryAlt("ArrowLeft")] }, + { id: "view.balancedSplit", label: "Balanced Split View", scope: "global", bindings: [primaryAlt("ArrowDown")] }, + { id: "view.fullPreview", label: "Full Width Preview", scope: "global", bindings: [primaryAlt("ArrowRight")] }, + + // Ctrl+Tab is the tab-switching convention on every platform, macOS included. + { id: "tab.next", label: "Next Tab", scope: "global", bindings: [{ key: "Tab", ctrl: true }] }, + { id: "tab.previous", label: "Previous Tab", scope: "global", bindings: [{ key: "Tab", ctrl: true, shift: true }] }, + ]; +} + +/** Canonical string for a chord, used to detect two shortcuts sharing one. */ +export function bindingSignature(binding) { + const mods = ["ctrl", "meta", "alt", "shift"].filter((mod) => binding[mod]); + return [...mods, binding.key.toLowerCase()].join("+"); +} diff --git a/frontend/tests/shortcut-definitions.test.mjs b/frontend/tests/shortcut-definitions.test.mjs new file mode 100644 index 00000000..54e89563 --- /dev/null +++ b/frontend/tests/shortcut-definitions.test.mjs @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { bindingSignature, buildDefaultShortcuts } from "../src/lib/shortcut-definitions.mjs"; + +// The real shortcut table, as the app builds it on each platform. +const PLATFORMS = { macOS: buildDefaultShortcuts(true), "Windows/Linux": buildDefaultShortcuts(false) }; + +// Chords the OS or basic text editing owns; an app shortcut must never take them. +const RESERVED = { + macOS: ["meta+q", "meta+h", "meta+m", "meta+c", "meta+v", "meta+a", "meta+x", "meta+tab", "meta+,"], + "Windows/Linux": ["ctrl+c", "ctrl+v", "ctrl+a", "ctrl+x", "alt+f4"], +}; + +for (const [platform, shortcuts] of Object.entries(PLATFORMS)) { + test(`${platform}: no two shortcuts share a key chord`, () => { + const owner = new Map(); + for (const shortcut of shortcuts) { + for (const binding of shortcut.bindings) { + const signature = bindingSignature(binding); + assert.ok(!owner.has(signature), `${signature} is bound to both ${owner.get(signature)} and ${shortcut.id}`); + owner.set(signature, shortcut.id); + } + } + }); + + test(`${platform}: no shortcut takes a system or clipboard chord`, () => { + for (const shortcut of shortcuts) { + for (const binding of shortcut.bindings) { + assert.ok(!RESERVED[platform].includes(bindingSignature(binding)), `${shortcut.id} uses ${bindingSignature(binding)}`); + } + } + }); + + test(`${platform}: every shortcut has a unique id, a label and a binding`, () => { + assert.equal(new Set(shortcuts.map((shortcut) => shortcut.id)).size, shortcuts.length); + for (const shortcut of shortcuts) { + assert.ok(shortcut.label, `${shortcut.id} has no label`); + assert.ok(shortcut.bindings.length > 0, `${shortcut.id} has no binding`); + } + }); +} + +test("both platforms expose the same commands", () => { + const ids = (shortcuts) => shortcuts.map((shortcut) => shortcut.id); + assert.deepEqual(ids(PLATFORMS.macOS), ids(PLATFORMS["Windows/Linux"])); +}); + +test("categories are contiguous, so the Shortcuts menu groups them cleanly", () => { + const categories = PLATFORMS.macOS.map((shortcut) => shortcut.id.split(".")[0]); + const runs = categories.filter((category, index) => category !== categories[index - 1]); + assert.equal(new Set(runs).size, runs.length, `categories split apart: ${runs.join(", ")}`); +}); + +test("primary modifier is Cmd on macOS and Ctrl elsewhere", () => { + const save = (shortcuts) => shortcuts.find((shortcut) => shortcut.id === "file.save").bindings[0]; + assert.equal(bindingSignature(save(PLATFORMS.macOS)), "meta+s"); + assert.equal(bindingSignature(save(PLATFORMS["Windows/Linux"])), "ctrl+s"); +});