diff --git a/apps/electron-app/src/main/menu.ts b/apps/electron-app/src/main/menu.ts index d673465..b9b32be 100644 --- a/apps/electron-app/src/main/menu.ts +++ b/apps/electron-app/src/main/menu.ts @@ -18,7 +18,6 @@ const appMenu: (MenuItemConstructorOptions | MenuItem)[] = isMac { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, - { role: 'editMenu' }, isMac ? { role: 'close' } : {}, ], }, @@ -35,77 +34,31 @@ export function createMenu(mainWindow: BrowserWindow) { { label: 'Insert node', accelerator: isMac ? 'Cmd+K' : 'Ctrl+K', - click: () => { - mainWindow.webContents.send('ipc-menu', { - success: true, - data: { button: 'add-node' }, - } satisfies MenuResponse); - }, - }, - { type: 'separator' }, - { - label: 'Undo', - accelerator: isMac ? 'Cmd+U' : 'Ctrl+U', - click: () => { - mainWindow.webContents.send('ipc-menu', { - success: true, - data: { button: 'undo' }, - } satisfies MenuResponse); - }, - }, - { - label: 'Redo', - accelerator: isMac ? 'Cmd+Shift+U' : 'Ctrl+Shift+U', - click: () => { - mainWindow.webContents.send('ipc-menu', { - success: true, - data: { button: 'redo' }, - } satisfies MenuResponse); - }, + click: () => sendMessage(mainWindow, 'add-node'), }, { type: 'separator' }, { label: 'Save flow', accelerator: isMac ? 'Cmd+S' : 'Ctrl+S', - click: () => { - mainWindow.webContents.send('ipc-menu', { - success: true, - data: { button: 'save-flow' }, - } satisfies MenuResponse); - }, + click: () => sendMessage(mainWindow, 'save-flow'), }, { id: 'autosave', label: 'Auto save', type: 'checkbox', checked: true, - click: menuItem => { - mainWindow.webContents.send('ipc-menu', { - success: true, - data: { button: 'toggle-autosave', args: menuItem.checked }, - } satisfies MenuResponse); - }, + click: ({ checked }) => sendMessage(mainWindow, 'toggle-autosave', checked), }, { type: 'separator' }, { label: 'New flow', accelerator: isMac ? 'Cmd+N' : 'Ctrl+N', - click: () => { - mainWindow.webContents.send('ipc-menu', { - success: true, - data: { button: 'new-flow' }, - } satisfies MenuResponse); - }, + click: () => sendMessage(mainWindow, 'new-flow'), }, { label: 'Export flow', accelerator: isMac ? 'Cmd+E' : 'Ctrl+E', - click: () => { - mainWindow.webContents.send('ipc-menu', { - success: true, - data: { button: 'export-flow' }, - } satisfies MenuResponse); - }, + click: () => sendMessage(mainWindow, 'export-flow'), }, { label: 'Import flow', @@ -114,22 +67,86 @@ export function createMenu(mainWindow: BrowserWindow) { const flow = await importFlow(); if (!flow) return; - mainWindow.webContents.send('ipc-menu', { - success: true, - data: { button: 'import-flow', args: flow }, - } satisfies MenuResponse); + sendMessage(mainWindow, 'import-flow', flow); }, }, { type: 'separator' }, { label: 'Fit flow in view', accelerator: isMac ? 'Cmd+O' : 'Ctrl+O', - click: async () => { - mainWindow.webContents.send('ipc-menu', { - success: true, - data: { button: 'fit-flow' }, - } satisfies MenuResponse); - }, + click: () => sendMessage(mainWindow, 'fit-flow'), + }, + { type: 'separator' }, + { + label: 'Edit', + submenu: [ + { + label: 'Undo', + accelerator: isMac ? 'Cmd+Z' : 'Ctrl+Z', + click: () => { + mainWindow.webContents.undo(); + sendMessage(mainWindow, 'undo'); + }, + }, + { + label: 'Redo', + accelerator: isMac ? 'Cmd+Shift+Z' : 'Ctrl+Shift+Z', + click: () => { + mainWindow.webContents.redo(); + sendMessage(mainWindow, 'redo'); + }, + }, + { type: 'separator' }, + { + label: 'Cut', + accelerator: isMac ? 'Cmd+X' : 'Ctrl+X', + click: () => { + mainWindow.webContents.cut(); + sendMessage(mainWindow, 'cut'); + }, + }, + { + label: 'Copy', + accelerator: isMac ? 'Cmd+C' : 'Ctrl+C', + click: () => { + mainWindow.webContents.copy(); + sendMessage(mainWindow, 'copy'); + }, + }, + { + label: 'Paste', + accelerator: isMac ? 'Cmd+V' : 'Ctrl+V', + click: () => { + mainWindow.webContents.paste(); + sendMessage(mainWindow, 'paste'); + }, + }, + { type: 'separator' }, + { + label: 'Select all', + accelerator: isMac ? 'Cmd+A' : 'Ctrl+A', + click: () => { + mainWindow.webContents.selectAll(); + sendMessage(mainWindow, 'select-all'); + }, + }, + { + label: 'Deselect all', + accelerator: 'Escape', + click: () => { + sendMessage(mainWindow, 'deselect-all'); + }, + }, + { type: 'separator' }, + { + label: 'Delete', + accelerator: isMac ? 'Backspace' : 'Backspace', + click: () => { + mainWindow.webContents.delete(); + sendMessage(mainWindow, 'delete'); + }, + }, + ], }, ], }, @@ -138,21 +155,11 @@ export function createMenu(mainWindow: BrowserWindow) { submenu: [ { label: 'Microcontroller settings', - click: () => { - mainWindow.webContents.send('ipc-menu', { - success: true, - data: { button: 'board-settings' }, - } satisfies MenuResponse); - }, + click: () => sendMessage(mainWindow, 'board-settings'), }, { label: 'MQTT settings', - click: () => { - mainWindow.webContents.send('ipc-menu', { - success: true, - data: { button: 'mqtt-settings' }, - } satisfies MenuResponse); - }, + click: () => sendMessage(mainWindow, 'mqtt-settings'), }, ], }, @@ -175,3 +182,10 @@ export function createMenu(mainWindow: BrowserWindow) { const menu = Menu.buildFromTemplate(menuTemplate); Menu.setApplicationMenu(menu); } + +function sendMessage(mainWindow: BrowserWindow, button: string, args?: any) { + mainWindow.webContents.send('ipc-menu', { + success: true, + data: { button, args }, + } satisfies MenuResponse); +} diff --git a/apps/electron-app/src/render/components/IpcMenuListener.tsx b/apps/electron-app/src/render/components/IpcMenuListener.tsx index 88a140d..28e839a 100644 --- a/apps/electron-app/src/render/components/IpcMenuListener.tsx +++ b/apps/electron-app/src/render/components/IpcMenuListener.tsx @@ -1,9 +1,15 @@ import { useReactFlow, type Edge, type Node } from '@xyflow/react'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useLocalStorage } from 'usehooks-ts'; import { FlowFile } from '../../common/types'; import { useSaveFlow } from '../hooks/useSaveFlow'; -import { useReactFlowStore } from '../stores/react-flow'; +import { + useDeselectAll, + useReactFlowStore, + useSelectAll, + useSelectedEdges, + useSelectNodes, +} from '../stores/react-flow'; import { MqttSettingsForm } from './forms/MqttSettingsForm'; import { AdvancedSettingsForm } from './forms/AdvancedSettingsForm'; import { useAppStore } from '../stores/app'; @@ -12,13 +18,29 @@ import { useShallow } from 'zustand/react/shallow'; export function IpcMenuListeners() { const { getNodes, getEdges, fitView } = useReactFlow(); - const { setEdges, setNodes, undo, redo } = useReactFlowStore(); + const { setEdges, setNodes, undo, redo, onNodesChange, onEdgesChange } = useReactFlowStore(); const { saveNodesAndEdges, setAutoSave } = useSaveFlow(); const [, setLocalNodes] = useLocalStorage('nodes', []); const [, setLocalEdges] = useLocalStorage('edges', []); const setOpen = useNewNodeStore(useShallow(state => state.setOpen)); const { settingsOpen, setSettingsOpen } = useAppStore(); + const [copiedNodes, setCopiedNodes] = useState([]); + const selectAll = useSelectAll(); + const deselectAll = useDeselectAll(); + const selectedNodes = useSelectNodes(); + const selectedEdges = useSelectedEdges(); + + function canTriggerAction() { + const selectedElement = window.document.activeElement as HTMLElement; + + if (selectedElement === window.document.body) return true; + if (selectedElement.classList.contains('react-flow__node')) return true; + if (selectedElement.classList.contains('react-flow__edge')) return true; + if (selectedElement.classList.contains('react-flow__nodesselection-rect')) return true; + + return false; + } useEffect(() => { return window.electron.ipcRenderer.on<{ button: string; args: any }>('ipc-menu', result => { @@ -56,19 +78,95 @@ export function IpcMenuListeners() { setNodes(nodes); setEdges(edges); break; + case 'fit-flow': + fitView({ + duration: 400, + padding: 0.15, + nodes: selectedNodes().length > 0 ? selectedNodes() : undefined, + }); + break; case 'undo': + if (!canTriggerAction()) break; undo(); break; case 'redo': + if (!canTriggerAction()) break; redo(); break; - case 'fit-flow': - fitView({ duration: 400, padding: 0.15 }); + case 'select-all': + if (!canTriggerAction()) break; + selectAll(); + break; + case 'deselect-all': + if (!canTriggerAction()) break; + deselectAll(); + break; + case 'copy': + if (!canTriggerAction()) break; + setCopiedNodes(selectedNodes()); + break; + case 'cut': + if (!canTriggerAction()) break; + setCopiedNodes(selectedNodes()); + onNodesChange( + selectedNodes().map(node => ({ + type: 'remove', + id: node.id, + })), + ); + break; + case 'paste': + if (!canTriggerAction()) break; + deselectAll(); + onNodesChange( + copiedNodes.map(node => ({ + type: 'add', + item: { + ...node, + id: Math.random().toString(36).substring(2, 8), + position: { + x: node.position.x + 20, + y: node.position.y + 20, + }, + selected: true, + dragging: true, + }, + })), + ); + break; + case 'delete': + if (!canTriggerAction()) break; + onNodesChange( + selectedNodes().map(node => ({ + type: 'remove', + id: node.id, + })), + ); + onEdgesChange( + selectedEdges().map(edge => ({ + type: 'remove', + id: edge.id, + })), + ); + break; default: break; } }); - }, [saveNodesAndEdges, setSettingsOpen, setOpen]); + }, [ + saveNodesAndEdges, + setSettingsOpen, + setOpen, + undo, + redo, + selectAll, + deselectAll, + selectedNodes, + onNodesChange, + selectedEdges, + onEdgesChange, + copiedNodes, + ]); if (settingsOpen === 'mqtt-settings') return ; if (settingsOpen === 'board-settings') return ; diff --git a/apps/electron-app/src/render/components/react-flow/nodes/Button.tsx b/apps/electron-app/src/render/components/react-flow/nodes/Button.tsx index 377bb17..0a06964 100644 --- a/apps/electron-app/src/render/components/react-flow/nodes/Button.tsx +++ b/apps/electron-app/src/render/components/react-flow/nodes/Button.tsx @@ -97,7 +97,7 @@ type Props = BaseNode; Button.defaultProps = { data: { group: 'hardware', - tags: ['digital', 'input'], + tags: ['input', 'digital'], holdtime: 500, isPulldown: false, isPullup: false, diff --git a/apps/electron-app/src/render/components/react-flow/nodes/Gate.tsx b/apps/electron-app/src/render/components/react-flow/nodes/Gate.tsx index 9b2b388..3f007c7 100644 --- a/apps/electron-app/src/render/components/react-flow/nodes/Gate.tsx +++ b/apps/electron-app/src/render/components/react-flow/nodes/Gate.tsx @@ -87,7 +87,7 @@ Gate.defaultProps = { tags: ['control', 'transformation'], label: 'Gate', gate: 'and', - description: 'Combine and validate input signals using logic gates', + description: 'Validate signals using logic gates', } satisfies Props['data'], }; diff --git a/apps/electron-app/src/render/components/react-flow/nodes/Node.tsx b/apps/electron-app/src/render/components/react-flow/nodes/Node.tsx index 8bef8ff..f1e581a 100644 --- a/apps/electron-app/src/render/components/react-flow/nodes/Node.tsx +++ b/apps/electron-app/src/render/components/react-flow/nodes/Node.tsx @@ -26,7 +26,6 @@ import { createPortal } from 'react-dom'; import { useUpdateNode } from '../../../hooks/useUpdateNode'; import { useDeleteEdges } from '../../../stores/react-flow'; import { NodeType } from '../../../../common/nodes'; -import { usePins } from '../../../stores/board'; import { useHotkey } from '../../../hooks/useHotkey'; export function NodeSettingsButton() { @@ -383,7 +382,7 @@ const node = cva( ); type NodeGroup = 'flow' | 'hardware' | 'external'; -type NodeTags = +export type NodeTags = | 'digital' | 'analog' | 'input' diff --git a/apps/electron-app/src/render/components/react-flow/nodes/Proximity.tsx b/apps/electron-app/src/render/components/react-flow/nodes/Proximity.tsx index b8d50c9..e0bb368 100644 --- a/apps/electron-app/src/render/components/react-flow/nodes/Proximity.tsx +++ b/apps/electron-app/src/render/components/react-flow/nodes/Proximity.tsx @@ -75,7 +75,7 @@ type Props = BaseNode; Proximity.defaultProps = { data: { group: 'hardware', - tags: ['analog', 'input'], + tags: ['input', 'analog'], freq: 25, pin: 'A0', controller: 'GP2Y0A21YK', diff --git a/apps/electron-app/src/render/components/react-flow/nodes/Relay.tsx b/apps/electron-app/src/render/components/react-flow/nodes/Relay.tsx index e39ca75..f4bcea7 100644 --- a/apps/electron-app/src/render/components/react-flow/nodes/Relay.tsx +++ b/apps/electron-app/src/render/components/react-flow/nodes/Relay.tsx @@ -71,7 +71,7 @@ Relay.defaultProps = { group: 'hardware', label: 'Relay', pin: 10, - tags: ['analog', 'digital', 'output'], + tags: ['output', 'analog', 'digital'], type: 'NO', description: 'Switch on or off high-power devices', } satisfies Props['data'], diff --git a/apps/electron-app/src/render/components/react-flow/nodes/Switch.tsx b/apps/electron-app/src/render/components/react-flow/nodes/Switch.tsx index 163d602..247758f 100644 --- a/apps/electron-app/src/render/components/react-flow/nodes/Switch.tsx +++ b/apps/electron-app/src/render/components/react-flow/nodes/Switch.tsx @@ -66,7 +66,7 @@ Switch.defaultProps = { pin: 2, group: 'hardware', label: 'Switch', - tags: ['digital', 'input'], + tags: ['input', 'digital'], type: 'NC', description: 'Control a switch to toggle between on and off states', } satisfies Props['data'], diff --git a/apps/electron-app/src/render/hooks/useHotkey.tsx b/apps/electron-app/src/render/hooks/useHotkey.tsx index 1011537..04ba1f3 100644 --- a/apps/electron-app/src/render/hooks/useHotkey.tsx +++ b/apps/electron-app/src/render/hooks/useHotkey.tsx @@ -6,19 +6,36 @@ type Options = { withShiftKey?: boolean; withAltKey?: boolean; isCorrectTarget?: (target: HTMLElement) => boolean; + /* + * When `undefined`, the default behavior is to prevent the default action. + */ + preventDefault?: boolean; }; type Action = (event: KeyboardEvent) => void; +function checkKey(isPressed: boolean, shouldBePressed?: boolean) { + if (shouldBePressed === undefined && !isPressed) return true; + if (!shouldBePressed && !isPressed) return true; + if (shouldBePressed && isPressed) return true; + + return false; +} + export function useHotkey(options: Options, action: Action) { useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.code !== options.code) return; - if (options.withMetaKey && !event.metaKey) return; - if (options.withShiftKey && !event.shiftKey) return; - if (options.withAltKey && !event.altKey) return; + const checkMeta = checkKey(event.metaKey, options.withMetaKey); + const checkShift = checkKey(event.shiftKey, options.withShiftKey); + const checkAlt = checkKey(event.altKey, options.withAltKey); + if (!checkMeta || !checkShift || !checkAlt) return; if (options.isCorrectTarget && !options.isCorrectTarget(event.target as HTMLElement)) return; + if (options.preventDefault === undefined) { + event.preventDefault(); + } + action(event); } diff --git a/apps/electron-app/src/render/providers/NewNodeProvider.tsx b/apps/electron-app/src/render/providers/NewNodeProvider.tsx index ebd5acb..7912ce8 100644 --- a/apps/electron-app/src/render/providers/NewNodeProvider.tsx +++ b/apps/electron-app/src/render/providers/NewNodeProvider.tsx @@ -1,5 +1,4 @@ import { - Badge, CommandDialog, CommandEmpty, CommandGroup, @@ -16,7 +15,7 @@ import { import { Node, useReactFlow } from '@xyflow/react'; import { useEffect, useMemo } from 'react'; import { NODE_TYPES } from '../../common/nodes'; -import { useDeleteSelectedNodesAndEdges, useNodesChange } from '../stores/react-flow'; +import { useNodesChange } from '../stores/react-flow'; import { useNewNodeStore } from '../stores/new-node'; import { useWindowSize } from 'usehooks-ts'; import { BaseNode } from '../components/react-flow/nodes/Node'; @@ -28,7 +27,6 @@ const NODE_SIZE = { export function NewNodeCommandDialog() { useDraggableNewNode(); - useBackspaceOverwrite(); const { open, setOpen, setNodeToAdd } = useNewNodeStore(); const { flowToScreenPosition, getZoom } = useReactFlow(); @@ -203,25 +201,3 @@ function useDraggableNewNode() { return null; } - -// https://github.com/xyflow/xyflow/issues/4761 -export function useBackspaceOverwrite() { - const deleteSelectedNodesAndEdges = useDeleteSelectedNodesAndEdges(); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const element = event.target as HTMLElement; - if (element !== document.body && !element.classList.contains('react-flow__node')) return; - if (event.code !== 'Backspace') return; - - console.log('deleteSelectedNodes'); - deleteSelectedNodesAndEdges(); - }; - - window.addEventListener('keydown', handleKeyDown); - - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, [deleteSelectedNodesAndEdges]); -} diff --git a/apps/electron-app/src/render/stores/react-flow.ts b/apps/electron-app/src/render/stores/react-flow.ts index 000d9b1..fe1297f 100644 --- a/apps/electron-app/src/render/stores/react-flow.ts +++ b/apps/electron-app/src/render/stores/react-flow.ts @@ -175,3 +175,37 @@ export function useEdges() { export function useDeleteSelectedNodesAndEdges() { return useReactFlowStore(useShallow(state => state.deleteSelectedNodesAndEdges)); } + +export function useSelectAll() { + return useReactFlowStore( + useShallow(state => () => { + state.onNodesChange( + state.nodes.map(node => ({ type: 'select', selected: true, id: node.id })), + ); + state.onEdgesChange( + state.edges.map(edge => ({ type: 'select', selected: true, id: edge.id })), + ); + }), + ); +} + +export function useDeselectAll() { + return useReactFlowStore( + useShallow(state => () => { + state.onNodesChange( + state.nodes.map(node => ({ type: 'select', selected: false, id: node.id })), + ); + state.onEdgesChange( + state.edges.map(edge => ({ type: 'select', selected: false, id: edge.id })), + ); + }), + ); +} + +export function useSelectNodes() { + return useReactFlowStore(useShallow(state => () => state.nodes.filter(node => node.selected))); +} + +export function useSelectedEdges() { + return useReactFlowStore(useShallow(state => () => state.edges.filter(edge => edge.selected))); +}