feat(editor): View and Language menus — full desktop-menu parity

The menubar now matches the desktop app's native menu set: File, Edit,
View, Language, Help.

View collects what the user asked to reach from a menu: Compile (Ctrl+B),
Run, Stop, Reset up top; the panel toggles — File Explorer, Output
Console, Serial Monitor, Oscilloscope/Logic Analyzer — in the middle
(store-backed ones render a live check, like a real desktop menu); and
the canvas view actions (center, zoom) move here from Edit, which goes
back to being undo/redo only, as menus have always worked.

Language lists the nine locales with the current one checked, switching
through the same switchLocale path the header globe uses — so language is
reachable from the menubar regardless of the account state, on top of
living in the signed-in account menu.

Run/Stop/Compile/Reset and the console toggle register through the
editorCommands seam from EditorToolbar (their handlers close over its
state); the explorer toggle from EditorPage; serial and scope call their
stores directly, same as undo/redo.
This commit is contained in:
David Montero Crespo 2026-07-30 21:05:55 +02:00
parent 3b7abfc4f7
commit d233b66cc9
4 changed files with 101 additions and 4 deletions

View File

@ -20,7 +20,11 @@
*/
import React, { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { useOscilloscopeStore } from '../../store/useOscilloscopeStore';
import { LOCALES, LOCALE_META, type Locale } from '../../i18n/config';
import { getLocaleFromPath, switchLocale } from '../../i18n/path';
import {
hasEditorCommand,
runEditorCommand,
@ -48,12 +52,19 @@ const SITE = import.meta.env.VITE_PRO_BUILD ? '' : 'https://velxio.dev';
export const EditorMenuBar: React.FC = () => {
const { t } = useTranslation();
const [open, setOpen] = useState<'file' | 'edit' | 'help' | null>(null);
const [open, setOpen] = useState<'file' | 'edit' | 'view' | 'lang' | 'help' | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
// Re-render when owners (un)register their commands.
useSyncExternalStore(subscribeEditorCommands, getEditorCommandsVersion);
const location = useLocation();
const navigate = useNavigate();
const currentLocale = getLocaleFromPath(location.pathname);
const serialOpen = useSimulatorStore((s) => s.serialMonitorOpen);
const toggleSerialMonitor = useSimulatorStore((s) => s.toggleSerialMonitor);
const scopeOpen = useOscilloscopeStore((s) => s.open);
const toggleOscilloscope = useOscilloscopeStore((s) => s.toggleOscilloscope);
const undo = useSimulatorStore((s) => s.undo);
const redo = useSimulatorStore((s) => s.redo);
const history = useSimulatorStore((s) => s.history);
@ -118,8 +129,19 @@ export const EditorMenuBar: React.FC = () => {
{ kind: 'link', href: GITHUB_URL, label: t('editor.menu.github', 'GitHub Repository') },
];
const editItems: Item[] = [
{ kind: 'separator' }, // placeholder: undo/redo render specially above
// Edit is undo/redo only (they render specially, with live history state);
// everything view-shaped lives in the View menu, like the desktop app.
const editItems: Item[] = [];
const viewItems: Item[] = [
{ kind: 'command', id: 'sim.compile', label: t('editor.toolbar.compile', 'Compile'), shortcut: 'Ctrl+B' },
{ kind: 'command', id: 'sim.run', label: t('editor.toolbar.run', 'Run') },
{ kind: 'command', id: 'sim.stop', label: t('editor.toolbar.stop', 'Stop') },
{ kind: 'command', id: 'sim.resetBoard', label: t('editor.toolbar.reset', 'Reset') },
{ kind: 'separator' },
{ kind: 'command', id: 'view.toggleExplorer', label: t('editor.menu.toggleExplorer', 'File Explorer') },
{ kind: 'command', id: 'view.toggleConsole', label: t('editor.menu.toggleConsole', 'Output Console') },
{ kind: 'separator' },
{ 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') },
@ -158,7 +180,7 @@ export const EditorMenuBar: React.FC = () => {
</button>
);
const menu = (which: 'file' | 'edit' | 'help', label: string, items: Item[]): React.ReactNode => (
const menu = (which: 'file' | 'edit' | 'view' | 'lang' | 'help', label: string, items: Item[]): React.ReactNode => (
<div className="emb-root" key={which}>
<button
className={`emb-trigger${open === which ? ' emb-trigger-open' : ''}`}
@ -171,6 +193,59 @@ export const EditorMenuBar: React.FC = () => {
</button>
{open === which && (
<div className="emb-menu" role="menu">
{which === 'view' && (
<>
<button
role="menuitemcheckbox"
aria-checked={serialOpen}
className="emb-item"
onClick={() => {
setOpen(null);
toggleSerialMonitor();
}}
>
<span>{t('editor.canvas.toggleSerialMonitor', 'Serial Monitor')}</span>
<span className="emb-shortcut">{serialOpen ? '✓' : ''}</span>
</button>
<button
role="menuitemcheckbox"
aria-checked={scopeOpen}
className="emb-item"
onClick={() => {
setOpen(null);
toggleOscilloscope();
}}
>
<span>{t('editor.menu.toggleScope', 'Oscilloscope / Logic Analyzer')}</span>
<span className="emb-shortcut">{scopeOpen ? '✓' : ''}</span>
</button>
<div className="emb-separator" />
</>
)}
{which === 'lang' && (
<>
{LOCALES.map((loc) => (
<button
key={loc}
role="menuitemradio"
aria-checked={currentLocale === loc}
className="emb-item"
onClick={() => {
setOpen(null);
if (loc === currentLocale) return;
navigate(
switchLocale(location.pathname, loc as Locale) +
location.search +
location.hash,
);
}}
>
<span>{LOCALE_META[loc].nativeName}</span>
<span className="emb-shortcut">{currentLocale === loc ? '✓' : ''}</span>
</button>
))}
</>
)}
{which === 'edit' && (
<>
<button
@ -217,6 +292,8 @@ export const EditorMenuBar: React.FC = () => {
<div className="editor-menubar" ref={rootRef}>
{menu('file', t('editor.menu.file', 'File'), fileItems)}
{menu('edit', t('editor.menu.edit', 'Edit'), editItems)}
{menu('view', t('editor.menu.view', 'View'), viewItems)}
{menu('lang', t('editor.menu.language', 'Language'), [])}
{menu('help', t('editor.menu.help', 'Help'), helpItems)}
</div>
);

View File

@ -1395,6 +1395,11 @@ export const EditorToolbar = ({
window.dispatchEvent(new CustomEvent('velxio-pro-replay-record-toggle', {
detail: { projectId: currentProject?.id ?? null },
})),
compile: () => void handleCompile(),
run: () => void handleRun(),
stop: () => handleStop(),
resetBoard: () => handleReset(),
toggleConsole: () => setConsoleOpen((v) => !v),
});
const menuCommandsRef = useRef(makeMenuCommands());
menuCommandsRef.current = makeMenuCommands();
@ -1408,6 +1413,11 @@ export const EditorToolbar = ({
registerEditorCommand('project.share', () => menuCommandsRef.current.share()),
registerEditorCommand('project.githubSync', () => menuCommandsRef.current.githubSync()),
registerEditorCommand('sim.record', () => menuCommandsRef.current.record()),
registerEditorCommand('sim.compile', () => menuCommandsRef.current.compile()),
registerEditorCommand('sim.run', () => menuCommandsRef.current.run()),
registerEditorCommand('sim.stop', () => menuCommandsRef.current.stop()),
registerEditorCommand('sim.resetBoard', () => menuCommandsRef.current.resetBoard()),
registerEditorCommand('view.toggleConsole', () => menuCommandsRef.current.toggleConsole()),
];
return () => offs.forEach((off) => off());
}, []);

View File

@ -29,6 +29,12 @@ export type EditorCommandId =
| 'project.githubSync'
| 'firmware.upload'
| 'sim.record'
| 'sim.compile'
| 'sim.run'
| 'sim.stop'
| 'sim.resetBoard'
| 'view.toggleExplorer'
| 'view.toggleConsole'
| 'view.reset'
| 'view.zoomIn'
| 'view.zoomOut';

View File

@ -255,9 +255,13 @@ export const EditorPage: React.FC = () => {
const offNew = registerEditorCommand('project.new', () => {
void handleNewClick();
});
const offExplorer = registerEditorCommand('view.toggleExplorer', () =>
setExplorerOpen((v) => !v),
);
return () => {
offSave();
offNew();
offExplorer();
};
}, [handleSaveClick, handleNewClick]);