/** * EditorMenuBar — File / Edit menus for the editor header. * * The editor grew a single toolbar row where every action, frequent or * rare, competed for the same pixels; on small screens the buttons * measurably overlapped. The classic fix is the classic desktop split: * things you do every minute stay as buttons (Run, Stop, board, Add), * things you do a few times per session move into menus. This is those * menus. * * Actions are invoked through the editorCommands registry — their real * owners (EditorPage, FileExplorer, EditorToolbar, SimulatorCanvas) * register handlers on mount, so nothing here duplicates logic and an * item whose owner is not mounted renders disabled. Undo/redo read the * canvas history from the store directly, mirroring the canvas buttons. * * Menubar behaviour follows the desktop convention: click opens, click * again closes, hovering a sibling while open switches menus, Escape and * outside clicks close. */ import React, { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { useTranslation } from 'react-i18next'; import { useSimulatorStore } from '../../store/useSimulatorStore'; import { hasEditorCommand, runEditorCommand, subscribeEditorCommands, getEditorCommandsVersion, type EditorCommandId, } from '../../lib/editorCommands'; import './EditorMenuBar.css'; type Item = | { kind: 'command'; id: EditorCommandId; label: string; shortcut?: string } | { kind: 'separator' }; export const EditorMenuBar: React.FC = () => { const { t } = useTranslation(); const [open, setOpen] = useState<'file' | 'edit' | null>(null); const rootRef = useRef(null); // Re-render when owners (un)register their commands. useSyncExternalStore(subscribeEditorCommands, getEditorCommandsVersion); const undo = useSimulatorStore((s) => s.undo); const redo = useSimulatorStore((s) => s.redo); const history = useSimulatorStore((s) => s.history); const historyIndex = useSimulatorStore((s) => s.historyIndex); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent): void => { if (!rootRef.current?.contains(e.target as Node)) setOpen(null); }; const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') setOpen(null); }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [open]); const fileItems: Item[] = [ { kind: 'command', id: 'project.new', label: t('editor.menu.newProject', 'New workspace') }, { kind: 'command', id: 'file.new', label: t('editor.menu.newFile', 'New file') }, { kind: 'separator' }, { kind: 'command', id: 'project.open', label: t('editor.menu.open', 'Open project…') }, { kind: 'command', id: 'project.save', label: t('editor.menu.save', 'Save project'), shortcut: 'Ctrl+S', }, { kind: 'separator' }, { kind: 'command', id: 'project.import', label: t('editor.toolbar.importLabel', 'Import project') }, { kind: 'command', id: 'project.export', label: t('editor.toolbar.exportLabel', 'Export project (.zip)') }, { kind: 'command', id: 'project.exportBom', label: t('editor.toolbar.exportBomLabel', 'Bill of Materials (CSV)') }, { kind: 'command', id: 'project.exportScreenshot', label: t('editor.toolbar.exportScreenshotLabel', 'Schematic image (PNG)'), }, ]; const editItems: Item[] = [ { kind: 'separator' }, // placeholder: undo/redo render specially above { kind: 'command', id: 'view.reset', label: t('editor.menu.centerView', 'Center canvas view') }, { kind: 'command', id: 'view.zoomIn', label: t('editor.canvas.zoomIn', 'Zoom in') }, { kind: 'command', id: 'view.zoomOut', label: t('editor.canvas.zoomOut', 'Zoom out') }, ]; const renderCommand = (item: Extract): React.ReactNode => ( ); const menu = (which: 'file' | 'edit', label: string, items: Item[]): React.ReactNode => (
{open === which && (
{which === 'edit' && ( <> )} {items.map((item, i) => item.kind === 'separator' ? (
) : ( renderCommand(item) ), )}
)}
); return (
{menu('file', t('editor.menu.file', 'File'), fileItems)} {menu('edit', t('editor.menu.edit', 'Edit'), editItems)}
); };