Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/KeyboardShortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand All @@ -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.

Expand Down
19 changes: 13 additions & 6 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 41 additions & 6 deletions frontend/src/hooks/editor/useActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand All @@ -49,6 +53,8 @@ export function useActions({
onOpenProject,
onFileSaved,
onShowPreview,
onToggleFocusMode,
onSelectTab,
}: UseActionsOptions) {
const { handleOpenFile, handleExport } = fileIO;
const {
Expand Down Expand Up @@ -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<Record<ActionId, () => void>>(
() => ({
"file.new": editor.newTab,
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand All @@ -140,6 +161,8 @@ export function useActions({
setSplit,
onOpenProject,
onShowPreview,
onToggleFocusMode,
selectAdjacentTab,
wrapSelection,
]
);
Expand Down Expand Up @@ -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"] },
Expand All @@ -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"] },
],
},
{
Expand All @@ -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]
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/hooks/editor/useEditorCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null | undefined>;
syncSelectedText: () => void;
};
Expand All @@ -47,6 +49,8 @@ export function useEditorCommands({
onOpenProject,
onFileSaved,
onShowPreview,
onToggleFocusMode,
onSelectTab,
executeAIPrompt,
syncSelectedText,
}: UseEditorCommandsOptions) {
Expand All @@ -66,6 +70,8 @@ export function useEditorCommands({
onOpenProject,
onFileSaved,
onShowPreview,
onToggleFocusMode,
onSelectTab,
});

// 3. Register keyboard shortcuts
Expand Down
108 changes: 13 additions & 95 deletions frontend/src/lib/keyboardShortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import { buildDefaultShortcuts } from "@/lib/shortcut-definitions.mjs";

export type ActionId =
| "file.new"
| "file.open"
Expand All @@ -21,14 +23,22 @@ export type ActionId =
| "format.heading2"
| "format.heading3"
| "format.normal"
| "format.strikethrough"
| "format.inlineCode"
| "format.link"
| "table.insert"
| "view.toggleDarkMode"
| "view.toggleAIPanel"
| "view.showPreview"
| "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;
Expand All @@ -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"> = {}): 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 = {}
Expand Down
Loading
Loading