velxio/frontend/src/components/editor/EditorMenuBar.tsx

171 lines
6.2 KiB
TypeScript
Raw Normal View History

feat(editor): menus File/Edit en la cabecera y editor sin nav de marketing La queja: demasiados botones en una sola fila, y en pantallas pequenas se solapan — medido, no impresion: a 1024px Code/Both/Circuit pisaban a Compile/Run/Stop por 9-20px y Add pisaba el chat por 29px, con el idioma y el usuario cortados fuera del viewport. Tres piezas: 1. En el editor, el nav de marketing (Home/Docs/Pricing/...) sobra: ya estas dentro, y cuesta exactamente el ancho que le falta a la toolbar. AppHeader gana la variante editorMenu — mismo mecanismo que ya usa la build de escritorio (VITE_DESKTOP) — que oculta el nav y pinta un menu File/Edit junto al logo. El logo sigue llevando a inicio; idioma, autoguardado, Share y usuario se quedan. 2. File/Edit al estilo escritorio: lo que se usa cada minuto sigue siendo boton; lo que se usa unas veces por sesion va al menu. File: nuevo workspace, nuevo fichero, abrir, guardar, importar, exportar, BOM, imagen del esquema. Edit: undo/redo (con su estado real del historial), centrar vista, zoom. Las acciones viven en cierres de cuatro componentes distintos, asi que hay un registro id->handler (lib/editorCommands, mismo patron de seam que registerProExamples o registerBoardBuiltins): cada dueno registra al montar y el menu invoca por id; un item sin dueno montado se pinta deshabilitado, que ademas es la verdad. El registro sobrevive al remontaje StrictMode (el cleanup viejo no borra al sucesor) y tiene test de eso. 3. La toolbar adelgaza: fuera Undo/Redo (Ctrl+Z/Y siguen), fuera los botones inline de Import/Export y sus gemelos responsivos del More (~70px recuperados); el More queda solo con los extras pro (firmware, GitHub, share, grabacion). El CSS responsivo de los botones retirados se va con ellos. Suite en verde (2300) y build del overlay verificado desde este arbol.
2026-07-30 21:26:36 +07:00
/**
* 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<HTMLDivElement>(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<Item, { kind: 'command' }>): React.ReactNode => (
<button
key={item.id}
role="menuitem"
className="emb-item"
disabled={!hasEditorCommand(item.id)}
onClick={() => {
setOpen(null);
runEditorCommand(item.id);
}}
>
<span>{item.label}</span>
{item.shortcut && <span className="emb-shortcut">{item.shortcut}</span>}
</button>
);
const menu = (which: 'file' | 'edit', label: string, items: Item[]): React.ReactNode => (
<div className="emb-root" key={which}>
<button
className={`emb-trigger${open === which ? ' emb-trigger-open' : ''}`}
aria-haspopup="menu"
aria-expanded={open === which}
onClick={() => setOpen((cur) => (cur === which ? null : which))}
onMouseEnter={() => setOpen((cur) => (cur && cur !== which ? which : cur))}
>
{label}
</button>
{open === which && (
<div className="emb-menu" role="menu">
{which === 'edit' && (
<>
<button
role="menuitem"
className="emb-item"
disabled={historyIndex < 0}
onClick={() => {
setOpen(null);
undo();
}}
>
<span>{t('editor.menu.undo', 'Undo')}</span>
<span className="emb-shortcut">Ctrl+Z</span>
</button>
<button
role="menuitem"
className="emb-item"
disabled={historyIndex >= history.length - 1}
onClick={() => {
setOpen(null);
redo();
}}
>
<span>{t('editor.menu.redo', 'Redo')}</span>
<span className="emb-shortcut">Ctrl+Y</span>
</button>
</>
)}
{items.map((item, i) =>
item.kind === 'separator' ? (
<div key={`sep-${i}`} className="emb-separator" />
) : (
renderCommand(item)
),
)}
</div>
)}
</div>
);
return (
<div className="editor-menubar" ref={rootRef}>
{menu('file', t('editor.menu.file', 'File'), fileItems)}
{menu('edit', t('editor.menu.edit', 'Edit'), editItems)}
</div>
);
};