/** * The node context menu. * * The layer tree and the canvas share one — right-clicking the same node in two places must * offer the same things, or the user has to remember "where do I click to get that". * * Why it has to exist: actions like delete were originally bound to the keyboard only, and * keyboard shortcuts do not arrive while focus is inside the canvas iframe (see the * forwarding section in canvas.js). Even with forwarding fixed, "right-click → delete" is * still most people's first instinct, and without it the feature effectively does not exist. * * The items are computed from the current selection rather than being a fixed list: a * greyed-out item helps nobody — either you can do it or you do not see it. The one exception * is deleting the root, which needs to explain why it is not possible. */ import { useEffect } from 'react'; import { __, sprintf } from './i18n.js'; export default function NodeMenu({ store, at, onClose, onAction }) { useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [onClose]); if (!at) return null; const ids = store.getSelected(); const id = at.id || store.getSelection(); const node = id && store.getNode(id); if (!node) return null; const scene = store.getScene(); const isRoot = id === scene.root; const isInstance = node.type === 'Instance'; const multi = ids.length > 1; const run = (fn) => () => { onClose(); fn(); }; const items = []; if (multi) { items.push({ label: sprintf(/* translators: %d: Number of selected elements. */ __('Group %d elements', 'aumframe'), ids.length), hint: '⌘G', act: () => onAction('group'), }); } if (!isRoot) { if (!multi) { items.push({ label: __('Duplicate', 'aumframe'), hint: '⌘D', act: () => store.duplicate(id) }); } if (!isInstance && (node.children?.length || 0) > 0) { items.push({ label: __('Save as component', 'aumframe'), act: () => onAction('component') }); } if (isInstance) { items.push({ label: __('Edit component', 'aumframe'), hint: __('every instance follows', 'aumframe'), act: () => store.enterComponent(node.props?.componentId), }); items.push({ label: __('Detach from the component', 'aumframe'), hint: __('flattens it into ordinary nodes', 'aumframe'), act: () => store.detachInstance(id), }); } if (!isInstance && (node.children?.length || 0) > 0) { items.push({ label: __('Ungroup', 'aumframe'), hint: '⇧⌘G', act: () => store.unwrap(id) }); } } if (items.length) items.push({ sep: true }); if (isRoot) { items.push({ label: __('The page itself cannot be deleted', 'aumframe'), disabled: true }); } else { items.push({ label: multi ? sprintf(/* translators: %d: Number of selected elements. */ __('Delete %d elements', 'aumframe'), ids.length) : __('Delete', 'aumframe'), hint: 'Delete', danger: true, act: () => { // Multi-select delete: collect first, then delete — deleting while iterating invalidates // the ids that follow const doomed = ids.length > 1 ? ids.filter((x) => x !== scene.root) : [id]; for (const d of doomed) store.remove(d); }, }); } return ( <>