From 19dd2d35fbad4e00bbc1ea1e61923155cbd8f528 Mon Sep 17 00:00:00 2001 From: Michael Ryom Date: Sun, 22 Feb 2026 16:36:08 +0100 Subject: [PATCH] feat: Add dynamic menu customization system Config-driven menu rendering with reorderable/hideable items, submenu item promotion to main menu, and vertical/horizontal orientation toggle. New files: - menu-config.ts: config types, defaults, validation - menu-customization.tsx: drag-to-reorder settings UI - promoted-items.tsx: renders promoted submenu items in main menu Modified: - menu/index.tsx: config-driven rendering with orientation toggle - jotai/device.ts: menuConfigAtom - storage/index.ts: menu config persistence - video, keyboard, mouse, settings submenus: config-driven item order --- browser/src/components/menu/index.tsx | 136 +++++- .../src/components/menu/keyboard/index.tsx | 18 +- browser/src/components/menu/mouse/index.tsx | 26 +- .../src/components/menu/promoted-items.tsx | 242 ++++++++++ .../src/components/menu/settings/index.tsx | 16 +- .../menu/settings/menu-customization.tsx | 457 ++++++++++++++++++ browser/src/components/menu/video/index.tsx | 21 +- browser/src/jotai/device.ts | 6 + browser/src/libs/menu-config.ts | 219 +++++++++ browser/src/libs/storage/index.ts | 26 + 10 files changed, 1125 insertions(+), 42 deletions(-) create mode 100644 browser/src/components/menu/promoted-items.tsx create mode 100644 browser/src/components/menu/settings/menu-customization.tsx create mode 100644 browser/src/libs/menu-config.ts diff --git a/browser/src/components/menu/index.tsx b/browser/src/components/menu/index.tsx index a5f1683c..80a718a0 100644 --- a/browser/src/components/menu/index.tsx +++ b/browser/src/components/menu/index.tsx @@ -2,25 +2,54 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Divider } from 'antd'; import clsx from 'clsx'; import { useAtomValue } from 'jotai'; -import { ChevronRightIcon, GripVerticalIcon, XIcon } from 'lucide-react'; +import { + ChevronRightIcon, + GripVerticalIcon, + XIcon, + ArrowRightLeftIcon, +} from 'lucide-react'; import Draggable from 'react-draggable'; -import { serialStateAtom } from '@/jotai/device.ts'; +import { menuConfigAtom, serialStateAtom } from '@/jotai/device.ts'; +import { + type MenuItemId, + type SubMenuItemId, + isSubMenuItem, + SERIAL_REQUIRED_ITEMS, + getSubMenuItemMeta, +} from '@/libs/menu-config'; import * as storage from '@/libs/storage'; import { Audio } from './audio'; import { Fullscreen } from './fullscreen'; import { Keyboard } from './keyboard'; import { Mouse } from './mouse'; +import { PromotedSubMenuItem } from './promoted-items'; import { Recorder } from './recorder'; import { SerialPort } from './serial-port'; import { Settings } from './settings'; import { Video } from './video'; +// Map MenuItemId to its component +const MENU_ITEM_COMPONENTS: Record = { + video: Video, + audio: Audio, + serialPort: SerialPort, + keyboard: Keyboard, + mouse: Mouse, + recorder: Recorder, + settings: Settings, + fullscreen: Fullscreen, +}; + export const Menu = () => { const serialState = useAtomValue(serialStateAtom); + const menuConfig = useAtomValue(menuConfigAtom); const [isMenuOpen, setIsMenuOpen] = useState(true); + const [orientation, setOrientation] = useState<'horizontal' | 'vertical'>( + storage.getMenuOrientation() + ); const [menuBounds, setMenuBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 }); const nodeRef = useRef(null); @@ -52,15 +81,71 @@ export const Menu = () => { useEffect(() => { handleResize(); - }, [isMenuOpen, serialState, handleResize]); + }, [isMenuOpen, serialState, menuConfig, orientation, handleResize]); function toggleMenu() { const isOpen = !isMenuOpen; - setIsMenuOpen(isOpen); storage.setIsMenuOpen(isOpen); } + function toggleOrientation() { + const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal'; + setOrientation(newOrientation); + storage.setMenuOrientation(newOrientation); + } + + // Build the list of rendered menu items with dividers between serial/non-serial groups + const renderMenuItems = () => { + const elements: React.ReactNode[] = []; + let lastRequiredSerial: boolean | null = null; + + for (const itemId of menuConfig.visibleItems) { + let requiresSerial: boolean; + let element: React.ReactNode; + + if (isSubMenuItem(itemId)) { + // Promoted submenu item + const meta = getSubMenuItemMeta(itemId as SubMenuItemId); + requiresSerial = meta?.requiresSerial ?? false; + + if (requiresSerial && serialState !== 'connected') continue; + + element = ; + } else { + const menuItemId = itemId as MenuItemId; + requiresSerial = SERIAL_REQUIRED_ITEMS.includes(menuItemId); + + if (requiresSerial && serialState !== 'connected') continue; + + const Component = MENU_ITEM_COMPONENTS[menuItemId]; + if (!Component) continue; + + element = ; + } + + // Add divider at serial/non-serial transitions + if (lastRequiredSerial !== null && lastRequiredSerial !== requiresSerial) { + const dividerType = orientation === 'horizontal' ? 'vertical' : 'horizontal'; + elements.push( + + ); + } + + elements.push(element); + lastRequiredSerial = requiresSerial; + } + + return elements; + }; + + const isVertical = orientation === 'vertical'; + return ( {
@@ -85,28 +174,25 @@ export const Menu = () => {
- - -