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
2 changes: 1 addition & 1 deletion frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} />
<TabBar tabs={editor.tabs} activeTabId={editor.activeTabId} onSelect={handleTabSelect} onClose={editor.closeTab} onNew={editor.newTab} />
<div className="flex flex-1 overflow-hidden">
{!focusMode && (
Expand Down
63 changes: 42 additions & 21 deletions frontend/src/components/MenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type MenuCommand = {
export type MenuGroup = {
label: string;
items: Array<MenuCommand | "separator">;
/** When set, the menu opens as a wide panel with these columns instead of a list. */
columns?: Array<{ heading: string; items: MenuCommand[] }>;
};

type Props = {
Expand All @@ -42,6 +44,27 @@ export default function MenuBar({ groups, shortcuts }: Props) {
event.preventDefault();
};

const renderCommand = (item: MenuCommand, compact = false) => (
<button
key={item.id}
type="button"
className={`flex w-full items-center justify-between ${compact ? "gap-4 px-2 py-1 rounded" : "gap-6 px-3 py-1.5"} text-left text-gray-700 hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-gray-700 dark:text-gray-200 dark:hover:bg-blue-950/40 dark:hover:text-blue-200 dark:disabled:hover:bg-transparent dark:disabled:hover:text-gray-200`}
onClick={() => {
item.onSelect();
setOpenMenu(null);
}}
onPointerDown={keepEditorFocusedOnPointerDown}
onMouseDown={keepEditorFocused}
disabled={item.disabled}
role="menuitem"
>
<span className="truncate">{item.label}</span>
<span className="shrink-0 whitespace-nowrap text-xs text-gray-400 dark:text-gray-500">
{shortcutLabelById.get(item.id) ?? ""}
</span>
</button>
);

useEffect(() => {
const closeOnOutsidePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
Expand Down Expand Up @@ -95,7 +118,24 @@ export default function MenuBar({ groups, shortcuts }: Props) {
>
{group.label}
</button>
{openMenu === group.label && (
{openMenu === group.label && group.columns && (
<div
className="fixed left-2 top-8 z-50 mt-0.5 grid max-h-[calc(100vh-3rem)] gap-x-4 gap-y-3 overflow-y-auto rounded-md border border-gray-200 bg-white p-3 text-[13px] shadow-lg dark:border-gray-700 dark:bg-[#252526]"
role="menu"
aria-label={group.label}
style={{ width: "min(60rem, calc(100vw - 1rem))", gridTemplateColumns: "repeat(auto-fit, minmax(12.5rem, 1fr))" }}
>
{group.columns.map((column) => (
<div key={column.heading} role="group" aria-label={column.heading}>
<div className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{column.heading}
</div>
{column.items.map((item) => renderCommand(item, true))}
</div>
))}
</div>
)}
{openMenu === group.label && !group.columns && (
<div
className="absolute left-0 top-full z-50 mt-0.5 max-h-[calc(100vh-3rem)] min-w-[220px] overflow-y-auto overflow-x-hidden rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-[#252526]"
role="menu"
Expand All @@ -110,26 +150,7 @@ export default function MenuBar({ groups, shortcuts }: Props) {
);
}

return (
<button
key={item.id}
type="button"
className="flex w-full items-center justify-between gap-6 px-3 py-1.5 text-left text-gray-700 hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-gray-700 dark:text-gray-200 dark:hover:bg-blue-950/40 dark:hover:text-blue-200 dark:disabled:hover:bg-transparent dark:disabled:hover:text-gray-200"
onClick={() => {
item.onSelect();
setOpenMenu(null);
}}
onPointerDown={keepEditorFocusedOnPointerDown}
onMouseDown={keepEditorFocused}
disabled={item.disabled}
role="menuitem"
>
<span>{item.label}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
{shortcutLabelById.get(item.id) ?? ""}
</span>
</button>
);
return renderCommand(item);
})}
</div>
)}
Expand Down
208 changes: 132 additions & 76 deletions frontend/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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<string, ActionId>;

export default function Toolbar({
onOpenFile,
onOpenProject,
Expand All @@ -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";
Expand Down Expand Up @@ -86,51 +104,66 @@ export default function Toolbar({
const keepEditorFocusedOnPointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
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 (
<div className="flex items-center gap-2 px-3 py-1.5 bg-white dark:bg-[#252526] border-b border-gray-200 dark:border-gray-700 shrink-0 text-sm">
<div className="flex items-center gap-1 px-3 py-1.5 bg-white dark:bg-[#252526] border-b border-gray-200 dark:border-gray-700 shrink-0 text-sm">
{/* File ops */}
<button onPointerDown={keepEditorFocusedOnPointerDown} onMouseDown={keepEditorFocused} onClick={onOpenFile} className={buttonClass(backendDisabled)} title="Open file" disabled={backendDisabled}>
📂 Open
</button>
<button onPointerDown={keepEditorFocusedOnPointerDown} onMouseDown={keepEditorFocused} onClick={onOpenProject} className={buttonClass(backendDisabled)} title="Open project folder" disabled={backendDisabled}>
📁 Project
</button>
<button onPointerDown={keepEditorFocusedOnPointerDown} onMouseDown={keepEditorFocused} onClick={onSaveFile} className={buttonClass(backendDisabled)} title="Save (Ctrl+S)" disabled={backendDisabled}>
💾 Save
</button>
<IconButton {...focusProps} icon="📂" label="Open file" title={tip("Open file", "file.open")} onClick={onOpenFile} disabled={backendDisabled} />
<IconButton {...focusProps} icon="📁" label="Open project folder" title={tip("Open project folder", "file.openFolder")} onClick={onOpenProject} disabled={backendDisabled} />
<IconButton {...focusProps} icon="💾" label="Save" title={tip("Save", "file.save")} onClick={onSaveFile} disabled={backendDisabled} />
{lastExportedPath && (
<button onClick={onShowInFinder} title="Show in Finder">
📁 Show in Finder
<button type="button" onClick={onShowInFinder} className={btnCls} title={`Show ${lastExportedPath}`}>
Show in Finder
</button>
)}

<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
<Divider />

{/* Export */}
<span className="text-gray-400 text-xs">Export:</span>
{/* Export: format names stay as text so they can be told apart. */}
<span className="text-gray-400 text-xs mr-0.5">Export:</span>
{(["html", "pdf", "docx"] as const).map((fmt) => (
<button key={fmt} onPointerDown={keepEditorFocusedOnPointerDown} onMouseDown={keepEditorFocused} onClick={() => onExport(fmt)} className={buttonClass(backendDisabled)} disabled={backendDisabled}>
<button
key={fmt}
type="button"
{...focusProps}
onClick={() => onExport(fmt)}
className={buttonClass(backendDisabled)}
title={tip(`Export to ${fmt.toUpperCase()}`, EXPORT_ACTIONS[fmt])}
disabled={backendDisabled}
>
{fmt.toUpperCase()}
</button>
))}

<button
onPointerDown={keepEditorFocusedOnPointerDown}
onMouseDown={keepEditorFocused}
<IconButton
{...focusProps}
icon="🌐"
label="Open rendered preview in browser"
title={tip("Open rendered preview in browser", "view.openBrowserPreview")}
onClick={onOpenBrowserPreview}
className={buttonClass(backendDisabled)}
title="Open rendered preview in browser"
disabled={backendDisabled}
>
🌐 Browser
</button>
/>

<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
<Divider />

{/* Font size */}
<span className="text-gray-500 dark:text-gray-400 text-xs">Font:</span>
<button onPointerDown={keepEditorFocusedOnPointerDown} onMouseDown={keepEditorFocused} onClick={() => onFontSizeChange(Math.max(MIN_FONT_SIZE, fontSize - 1))} className={btnCls}>A-</button>
<span className="text-gray-500 dark:text-gray-400 text-xs mr-0.5">Font:</span>
<button
type="button"
{...focusProps}
onClick={() => onFontSizeChange(Math.max(MIN_FONT_SIZE, fontSize - 1))}
className={btnCls}
title="Decrease font size"
aria-label="Decrease font size"
>
A-
</button>

<input
type="number"
Expand Down Expand Up @@ -164,7 +197,16 @@ export default function Toolbar({
className="w-12 text-center text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-[#252526] text-gray-700 dark:text-gray-300"
/>

<button onPointerDown={keepEditorFocusedOnPointerDown} onMouseDown={keepEditorFocused} onClick={() => onFontSizeChange(Math.min(MAX_FONT_SIZE, fontSize + 1))} className={btnCls}>A+</button>
<button
type="button"
{...focusProps}
onClick={() => onFontSizeChange(Math.min(MAX_FONT_SIZE, fontSize + 1))}
className={btnCls}
title="Increase font size"
aria-label="Increase font size"
>
A+
</button>

<div className="flex-1" />

Expand All @@ -180,60 +222,74 @@ export default function Toolbar({
)}

{/* Toggles */}
<button
onClick={onToggleFocusMode}
className={`${btnCls} ${showFocusPanel ? "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300" : ""}`}
title="Focus Mode"
>
🧠 Focus
</button>
<button
onPointerDown={keepEditorFocusedOnPointerDown}
onMouseDown={keepEditorFocused}
onClick={() => onTogglePanel(PANELS.PREVIEW)}
className={`${btnCls} ${activePanel === PANELS.PREVIEW ? "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300" : ""}`}
title="Preview"
>
👁 Preview
</button>
<button
onPointerDown={keepEditorFocusedOnPointerDown}
onMouseDown={keepEditorFocused}
<IconButton icon="🧠" label="Focus Mode" onClick={onToggleFocusMode} pressed={showFocusPanel} />
<IconButton {...focusProps} icon="👁" label="Preview" onClick={() => onTogglePanel(PANELS.PREVIEW)} pressed={activePanel === PANELS.PREVIEW} />
<IconButton
{...focusProps}
icon="🤖"
label="AI Assistant"
title={tip("AI Assistant", "view.toggleAIPanel")}
onClick={() => onTogglePanel(PANELS.AI)}
className={`${btnCls} ${activePanel === PANELS.AI ? "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300" : ""}`}
title="AI Assistant"
>
🤖 AI
</button>
<button
onPointerDown={keepEditorFocusedOnPointerDown}
onMouseDown={keepEditorFocused}
onClick={() => onTogglePanel(PANELS.CITATION)}
className={`${btnCls} ${activePanel === PANELS.CITATION ? "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300" : ""}`}
title="Citations"
>
📚 Cite
</button>
<button
onClick={() => onTogglePanel(PANELS.PUBLICATION)}
title="Publication"
aria-pressed={activePanel === PANELS.PUBLICATION}
className={`rounded p-1.5 ${activePanel === PANELS.PUBLICATION ? "bg-blue-100 text-blue-700" : "text-gray-600 hover:bg-gray-100"} dark:${activePanel === PANELS.PUBLICATION ? "bg-[#333] text-blue-400" : "text-gray-300 dark:hover:bg-[#333]"}`}
>
📝Publication
</button>
<button onPointerDown={keepEditorFocusedOnPointerDown} onMouseDown={keepEditorFocused} onClick={onToggleDark} className={btnCls} title="Toggle dark mode">
{darkMode ? "☀️" : "🌙"}
</button>
pressed={activePanel === PANELS.AI}
/>
<IconButton {...focusProps} icon="📚" label="Citations" onClick={() => onTogglePanel(PANELS.CITATION)} pressed={activePanel === PANELS.CITATION} />
<IconButton icon="📝" label="Publication" onClick={() => onTogglePanel(PANELS.PUBLICATION)} pressed={activePanel === PANELS.PUBLICATION} />
<IconButton
{...focusProps}
icon={darkMode ? "☀️" : "🌙"}
label={darkMode ? "Switch to light mode" : "Switch to dark mode"}
title={tip(darkMode ? "Switch to light mode" : "Switch to dark mode", "view.toggleDarkMode")}
onClick={onToggleDark}
/>
</div>
);
}

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<HTMLButtonElement>) => void;
onMouseDown?: (event: ReactMouseEvent<HTMLButtonElement>) => void;
};

function IconButton({ icon, label, title, onClick, disabled = false, pressed, onPointerDown, onMouseDown }: IconButtonProps) {
return (
<button
type="button"
onPointerDown={onPointerDown}
onMouseDown={onMouseDown}
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={pressed}
title={title ?? label}
className={`${iconBtnCls} ${pressed ? activeCls : ""} ${disabled ? disabledCls : ""}`}
>
<span aria-hidden="true">{icon}</span>
</button>
);
}

function Divider() {
return <div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1.5" />;
}

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;
}
Loading
Loading