From 8af6fd5f73e9fbdf3689729d844b4a4f2df1fe06 Mon Sep 17 00:00:00 2001 From: Hilton Vusi Kholokholo <68604335+Damox101za@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:22:25 +0200 Subject: [PATCH 1/2] Make toolbar buttons icon-only where the icon is clear (#457) Open, Project, Save, Browser, Focus, Preview, AI, Cite and Publication now show just their icon. Export formats (HTML, PDF, DOCX) and the font controls keep their text. Every icon button has a tooltip and an accessible label, and toggles report whether they are on. Tooltips show the action's current shortcut, so they follow the user's own key bindings and show Cmd on macOS. The Publication button now matches the other toggles, which also fixes its dark-mode colours. Co-Authored-By: Claude Opus 5.5 --- frontend/src/app/page.tsx | 2 +- frontend/src/components/Toolbar.tsx | 208 ++++++++++++++++++---------- 2 files changed, 133 insertions(+), 77 deletions(-) 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/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; } From be2296900671738dc0ab883ce684556a00a78fc2 Mon Sep 17 00:00:00 2001 From: Hilton Vusi Kholokholo <68604335+Damox101za@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:22:25 +0200 Subject: [PATCH 2/2] Show the Shortcuts menu as a compact panel grouped by category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Shortcuts menu was one long list of 26 entries in mixed order. It now opens as a panel with File, Edit, Format and View columns, with related actions kept together (headings in order, exports after save). Columns wrap on narrow windows and every entry is still clickable. Arrow keys are shown as arrows (Ctrl+Alt+← rather than Ctrl+Alt+ArrowLeft) wherever shortcuts are displayed. The grouping lives in lib/shortcut-groups.mjs with Node tests. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/MenuBar.tsx | 63 ++++++++++++++++--------- frontend/src/hooks/editor/useActions.ts | 14 ++++-- frontend/src/lib/keyboardShortcuts.ts | 10 +++- frontend/src/lib/shortcut-groups.mjs | 60 +++++++++++++++++++++++ frontend/tests/shortcut-groups.test.mjs | 44 +++++++++++++++++ 5 files changed, 165 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/shortcut-groups.mjs create mode 100644 frontend/tests/shortcut-groups.test.mjs 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/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); +});