diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index ed846b0e..5527663a 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -143,7 +143,7 @@ export default function HomePage() {
darkMode={editor.darkMode} fontSize={editor.fontSize} onFontSizeChange={editor.setFontSize}
showFocusPanel={focusMode}
backendStatus={showPackagedBackendStatus ? backendStatus : "ready"} backendMessage={showPackagedBackendStatus ? backendMessage : null}
- lastExportedPath={fileIO.lastExportedPath} onShowInFinder={fileIO.showInFinder} />
+ lastExportedPath={fileIO.lastExportedPath} onShowInFinder={fileIO.showInFinder} shortcuts={shortcuts} />
{!focusMode && (
diff --git a/frontend/src/components/MenuBar.tsx b/frontend/src/components/MenuBar.tsx
index e0b01ae3..a98b0621 100644
--- a/frontend/src/components/MenuBar.tsx
+++ b/frontend/src/components/MenuBar.tsx
@@ -22,6 +22,8 @@ export type MenuCommand = {
export type MenuGroup = {
label: string;
items: Array
;
+ /** When set, the menu opens as a wide panel with these columns instead of a list. */
+ columns?: Array<{ heading: string; items: MenuCommand[] }>;
};
type Props = {
@@ -42,6 +44,27 @@ export default function MenuBar({ groups, shortcuts }: Props) {
event.preventDefault();
};
+ const renderCommand = (item: MenuCommand, compact = false) => (
+
+ );
+
useEffect(() => {
const closeOnOutsidePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
@@ -95,7 +118,24 @@ export default function MenuBar({ groups, shortcuts }: Props) {
>
{group.label}
- {openMenu === group.label && (
+ {openMenu === group.label && group.columns && (
+
+ {group.columns.map((column) => (
+
+
+ {column.heading}
+
+ {column.items.map((item) => renderCommand(item, true))}
+
+ ))}
+
+ )}
+ {openMenu === group.label && !group.columns && (
{
- item.onSelect();
- setOpenMenu(null);
- }}
- onPointerDown={keepEditorFocusedOnPointerDown}
- onMouseDown={keepEditorFocused}
- disabled={item.disabled}
- role="menuitem"
- >
- {item.label}
-
- {shortcutLabelById.get(item.id) ?? ""}
-
-
- );
+ return renderCommand(item);
})}
)}
diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx
index 29c1e57a..8133a298 100644
--- a/frontend/src/components/Toolbar.tsx
+++ b/frontend/src/components/Toolbar.tsx
@@ -4,6 +4,9 @@
* Toolbar.tsx
* ===========
* Top action bar: file open/save, export, view toggles, settings.
+ *
+ * Buttons whose icon says enough are icon-only, with a tooltip and an
+ * accessible label. Export formats and font controls keep their text.
*/
import { useEffect, useState } from "react";
@@ -13,6 +16,12 @@ import type {
} from "react";
import { PANELS, type PanelId } from "@/types/panels";
+import {
+ DEFAULT_SHORTCUTS,
+ formatShortcut,
+ type ActionId,
+ type ShortcutDefinition,
+} from "@/lib/keyboardShortcuts";
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 72;
@@ -35,8 +44,16 @@ type Props = {
backendMessage?: string | null;
lastExportedPath?: string | null;
onShowInFinder?: () => void;
+ /** Current shortcuts, so tooltips show the user's own key bindings. */
+ shortcuts?: ShortcutDefinition[];
};
+const EXPORT_ACTIONS = {
+ html: "file.exportHtml",
+ pdf: "file.exportPdf",
+ docx: "file.exportDocx",
+} as const satisfies Record;
+
export default function Toolbar({
onOpenFile,
onOpenProject,
@@ -55,6 +72,7 @@ export default function Toolbar({
backendStatus = "ready",
backendMessage = null,
lastExportedPath = null,
+ shortcuts = DEFAULT_SHORTCUTS,
}: Props) {
const backendBusy = backendStatus === "starting";
const backendFailed = backendStatus === "error";
@@ -86,51 +104,66 @@ export default function Toolbar({
const keepEditorFocusedOnPointerDown = (event: ReactPointerEvent) => {
event.preventDefault();
};
+ const focusProps = { onPointerDown: keepEditorFocusedOnPointerDown, onMouseDown: keepEditorFocused };
+
+ // "Save (Ctrl+S)", or just "Save" when the action has no shortcut.
+ const tip = (label: string, id?: ActionId) => {
+ const keys = id ? formatShortcut(shortcuts.find((s) => s.id === id)?.bindings[0]) : "";
+ return keys ? `${label} (${keys})` : label;
+ };
return (
-
+
{/* File ops */}
-
-
-
+
+
+
{lastExportedPath && (
-
);
}
+type IconButtonProps = {
+ icon: string;
+ /** Read by screen readers, and the tooltip unless `title` is given. */
+ label: string;
+ title?: string;
+ onClick: () => void;
+ disabled?: boolean;
+ /** For toggles: whether the panel or mode is on. Leave out for plain actions. */
+ pressed?: boolean;
+ onPointerDown?: (event: ReactPointerEvent
) => void;
+ onMouseDown?: (event: ReactMouseEvent) => void;
+};
+
+function IconButton({ icon, label, title, onClick, disabled = false, pressed, onPointerDown, onMouseDown }: IconButtonProps) {
+ return (
+
+ {icon}
+
+ );
+}
+
+function Divider() {
+ return ;
+}
+
const btnCls =
"px-2 py-0.5 rounded text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-[#2d2d2d] transition-colors";
+const iconBtnCls =
+ "inline-flex items-center justify-center w-7 h-7 rounded text-base leading-none text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-[#2d2d2d] transition-colors";
+
+const activeCls = "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300";
+
+const disabledCls = "opacity-40 cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent";
+
function buttonClass(disabled: boolean) {
- return disabled
- ? `${btnCls} opacity-40 cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent`
- : btnCls;
+ return disabled ? `${btnCls} ${disabledCls}` : btnCls;
}
diff --git a/frontend/src/hooks/editor/useActions.ts b/frontend/src/hooks/editor/useActions.ts
index 34c68e8e..51a2b71a 100644
--- a/frontend/src/hooks/editor/useActions.ts
+++ b/frontend/src/hooks/editor/useActions.ts
@@ -14,6 +14,7 @@ import {
type ActionId,
type ShortcutDefinition,
} from "@/lib/keyboardShortcuts";
+import { groupShortcuts } from "@/lib/shortcut-groups.mjs";
type EditorController = ReturnType;
type FileIOController = ReturnType;
@@ -207,10 +208,15 @@ export function useActions({
},
{
label: "Shortcuts",
- items: shortcuts.map((shortcut) => ({
- id: shortcut.id,
- label: shortcut.label,
- onSelect: actions[shortcut.id],
+ items: [],
+ // Shown as a compact panel with one column per category.
+ columns: groupShortcuts(shortcuts).map((column) => ({
+ heading: column.heading,
+ items: column.items.map((shortcut) => ({
+ id: shortcut.id,
+ label: shortcut.label,
+ onSelect: actions[shortcut.id],
+ })),
})),
},
],
diff --git a/frontend/src/lib/keyboardShortcuts.ts b/frontend/src/lib/keyboardShortcuts.ts
index f2337d8e..96c853ba 100644
--- a/frontend/src/lib/keyboardShortcuts.ts
+++ b/frontend/src/lib/keyboardShortcuts.ts
@@ -199,10 +199,18 @@ export function formatShortcut(binding?: ShortcutBinding) {
if (binding.ctrl) parts.push("Ctrl");
if (binding.alt) parts.push(isMac ? "Option" : "Alt");
if (binding.shift) parts.push("Shift");
- parts.push(binding.key.length === 1 ? binding.key.toUpperCase() : binding.key);
+ parts.push(binding.key.length === 1 ? binding.key.toUpperCase() : KEY_SYMBOLS[binding.key] ?? binding.key);
return parts.join("+");
}
+// Shorter names for keys shown in menus and tooltips.
+const KEY_SYMBOLS: Record = {
+ ArrowLeft: "β",
+ ArrowRight: "β",
+ ArrowUp: "β",
+ ArrowDown: "β",
+};
+
export function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
diff --git a/frontend/src/lib/shortcut-groups.mjs b/frontend/src/lib/shortcut-groups.mjs
new file mode 100644
index 00000000..ae50df0e
--- /dev/null
+++ b/frontend/src/lib/shortcut-groups.mjs
@@ -0,0 +1,60 @@
+// shortcut-groups.mjs
+// Splits the shortcut list into File / Edit / Format / View columns for the
+// Shortcuts menu. Plain .mjs (not .ts) so the Node test suite can import it.
+
+/** Column order, and which action id prefixes belong in each column. */
+export const SHORTCUT_CATEGORIES = [
+ { heading: "File", prefixes: ["file."] },
+ { heading: "Edit", prefixes: ["edit."] },
+ { heading: "Format", prefixes: ["format.", "table."] },
+ { heading: "View", prefixes: ["view."] },
+];
+
+// Within a column, actions listed here come first in this order; anything
+// else keeps its original order after them.
+const PREFERRED_ORDER = [
+ "file.new",
+ "file.open",
+ "file.openFolder",
+ "file.save",
+ "file.closeTab",
+ "file.closeAllTabs",
+ "file.exportHtml",
+ "file.exportPdf",
+ "file.exportDocx",
+ "format.bold",
+ "format.italic",
+ "format.underline",
+ "format.heading1",
+ "format.heading2",
+ "format.heading3",
+ "format.normal",
+ "table.insert",
+];
+
+/**
+ * @template {{ id: string }} T
+ * @param {T[]} shortcuts
+ * @returns {{ heading: string, items: T[] }[]} non-empty columns only
+ */
+export function groupShortcuts(shortcuts) {
+ const rank = (id) => {
+ const i = PREFERRED_ORDER.indexOf(id);
+ return i === -1 ? PREFERRED_ORDER.length : i;
+ };
+ const columns = SHORTCUT_CATEGORIES.map((category) => ({
+ heading: category.heading,
+ items: shortcuts
+ .map((shortcut, index) => ({ shortcut, index }))
+ .filter(({ shortcut }) => category.prefixes.some((p) => shortcut.id.startsWith(p)))
+ .sort((a, b) => rank(a.shortcut.id) - rank(b.shortcut.id) || a.index - b.index)
+ .map(({ shortcut }) => shortcut),
+ }));
+
+ // Anything with an unknown prefix still shows up, in an "Other" column.
+ const known = new Set(columns.flatMap((c) => c.items));
+ const other = shortcuts.filter((s) => !known.has(s));
+ if (other.length) columns.push({ heading: "Other", items: other });
+
+ return columns.filter((c) => c.items.length > 0);
+}
diff --git a/frontend/tests/shortcut-groups.test.mjs b/frontend/tests/shortcut-groups.test.mjs
new file mode 100644
index 00000000..c34c2e80
--- /dev/null
+++ b/frontend/tests/shortcut-groups.test.mjs
@@ -0,0 +1,44 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+// Covers how the Shortcuts menu splits shortcuts into columns.
+import { groupShortcuts } from "../src/lib/shortcut-groups.mjs";
+
+const ids = (column) => column.items.map((s) => s.id);
+const s = (id) => ({ id, label: id });
+
+test("shortcuts are split into File, Edit, Format and View columns in that order", () => {
+ const columns = groupShortcuts([s("view.toggleDarkMode"), s("format.bold"), s("edit.undo"), s("file.save")]);
+ assert.deepEqual(columns.map((c) => c.heading), ["File", "Edit", "Format", "View"]);
+});
+
+test("table shortcuts go in the Format column", () => {
+ const [format] = groupShortcuts([s("format.bold"), s("table.insert")]);
+ assert.equal(format.heading, "Format");
+ assert.deepEqual(ids(format), ["format.bold", "table.insert"]);
+});
+
+test("related actions sit together, e.g. headings in order and exports after save", () => {
+ const [file, format] = groupShortcuts([
+ s("format.heading2"),
+ s("file.exportPdf"),
+ s("format.bold"),
+ s("file.new"),
+ s("format.heading1"),
+ s("file.save"),
+ ]);
+ assert.deepEqual(ids(file), ["file.new", "file.save", "file.exportPdf"]);
+ assert.deepEqual(ids(format), ["format.bold", "format.heading1", "format.heading2"]);
+});
+
+test("actions without a preferred position keep their original order", () => {
+ const [view] = groupShortcuts([s("view.fullPreview"), s("view.toggleDarkMode"), s("view.fullEditor")]);
+ assert.deepEqual(ids(view), ["view.fullPreview", "view.toggleDarkMode", "view.fullEditor"]);
+});
+
+test("empty columns are left out and nothing is dropped", () => {
+ const input = [s("view.toggleDarkMode"), s("mystery.action")];
+ const columns = groupShortcuts(input);
+ assert.deepEqual(columns.map((c) => c.heading), ["View", "Other"]);
+ assert.equal(columns.flatMap((c) => c.items).length, input.length);
+});