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 && ( - )} -
+ - {/* Export */} - Export: + {/* Export: format names stay as text so they can be told apart. */} + Export: {(["html", "pdf", "docx"] as const).map((fmt) => ( - ))} - - + /> -
+ {/* Font size */} - Font: - + Font: + - +
@@ -180,60 +222,74 @@ export default function Toolbar({ )} {/* Toggles */} - - - - - - + pressed={activePanel === PANELS.AI} + /> + onTogglePanel(PANELS.CITATION)} pressed={activePanel === PANELS.CITATION} /> + onTogglePanel(PANELS.PUBLICATION)} pressed={activePanel === PANELS.PUBLICATION} /> +
); } +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 ( + + ); +} + +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); +});