/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Context menu for entity interactions */ import { useCallback, useEffect, useLayoutEffect, useRef, useMemo, useState } from 'react'; import { Equal, Plus, Minus, EyeOff, Eye, Layers, Copy, Maximize2, Building2, Save, Trash2, CopyPlus, } from 'lucide-react'; import { useViewerStore, resolveEntityRef } from '@/store'; import type { DuplicateDirection } from '@/store/slices/mutationSlice'; import { resetVisibilityForHomeFromStore } from '@/store/homeView'; import { executeBasketSet, executeBasketAdd, executeBasketRemove, executeBasketSaveView, } from '@/store/basket/basketCommands'; import { useIfc } from '@/hooks/useIfc'; import { toast } from '@/components/ui/toast'; import { useSlotContributions } from '@/hooks/useSlotContributions'; import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider'; import { evaluateWhen, parseWhen, type CommandContribution, type ResolvedContextMenuContribution } from '@ifc-lite/extensions'; import { resolveExtensionIcon } from '@/components/extensions/icon-registry'; import { describeRunCommandError } from '@/services/extensions/runtime-errors'; export function EntityContextMenu() { const contextMenu = useViewerStore((s) => s.contextMenu); const closeContextMenu = useViewerStore((s) => s.closeContextMenu); const hideEntity = useViewerStore((s) => s.hideEntity); const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId); const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds); const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks); // Store-level mutations const removeEntity = useViewerStore((s) => s.removeEntity); const duplicateEntity = useViewerStore((s) => s.duplicateEntity); const getMutationView = useViewerStore((s) => s.getMutationView); // Basket actions const menuRef = useRef(null); const { ifcDataStore, models } = useIfc(); // Resolve contextMenu.entityId (globalId) to original expressId and model // This is needed because IfcDataStore uses original expressIds, not globalIds const { resolvedExpressId, activeDataStore, contextEntityRef } = useMemo(() => { if (!contextMenu.entityId) { return { resolvedExpressId: null, activeDataStore: ifcDataStore, contextEntityRef: null }; } // Single source of truth for globalId → EntityRef resolution const ref = resolveEntityRef(contextMenu.entityId); if (ref) { const model = models.get(ref.modelId); return { resolvedExpressId: ref.expressId, activeDataStore: model?.ifcDataStore ?? ifcDataStore, contextEntityRef: ref, }; } return { resolvedExpressId: contextMenu.entityId, activeDataStore: ifcDataStore, contextEntityRef: null, }; }, [contextMenu.entityId, models, ifcDataStore]); // Close menu when clicking/tapping outside. // // Listen on `pointerdown` (with capture) rather than `mousedown`: // the canvas calls `e.preventDefault()` on its own pointerdown // handler, which in some browsers suppresses the compatibility // `mousedown` event — so a plain `mousedown` listener never fires // when the user clicks the 3D viewport to dismiss the menu. useEffect(() => { if (!contextMenu.isOpen) return; const handlePointerOutside = (e: PointerEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { closeContextMenu(); } }; // Also close on scroll/resize — the anchor coords go stale. const handleDismiss = () => closeContextMenu(); document.addEventListener('pointerdown', handlePointerOutside, true); window.addEventListener('resize', handleDismiss); window.addEventListener('wheel', handleDismiss, { passive: true }); return () => { document.removeEventListener('pointerdown', handlePointerOutside, true); window.removeEventListener('resize', handleDismiss); window.removeEventListener('wheel', handleDismiss); }; }, [contextMenu.isOpen, closeContextMenu]); // Close on escape useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { closeContextMenu(); } }; if (contextMenu.isOpen) { document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); } }, [contextMenu.isOpen, closeContextMenu]); const handleZoomTo = useCallback(() => { if (contextMenu.entityId) { setSelectedEntityId(contextMenu.entityId); cameraCallbacks.fitAll?.(); } closeContextMenu(); }, [contextMenu.entityId, setSelectedEntityId, cameraCallbacks, closeContextMenu]); // Basket: = Set basket to this entity const handleSetBasket = useCallback(() => { executeBasketSet(contextEntityRef); closeContextMenu(); }, [contextEntityRef, closeContextMenu]); // Basket: + Add to basket const handleAddToBasket = useCallback(() => { executeBasketAdd(contextEntityRef); closeContextMenu(); }, [contextEntityRef, closeContextMenu]); // Basket: − Remove from basket const handleRemoveFromBasket = useCallback(() => { executeBasketRemove(contextEntityRef); closeContextMenu(); }, [contextEntityRef, closeContextMenu]); const handleSaveBasketView = useCallback(() => { const state = useViewerStore.getState(); if (state.pinboardEntities.size === 0) { closeContextMenu(); return; } executeBasketSaveView().catch((err) => { console.error('[EntityContextMenu] Failed to save basket view:', err); }); closeContextMenu(); }, [closeContextMenu]); const handleHide = useCallback(() => { if (contextMenu.entityId) { hideEntity(contextMenu.entityId); } closeContextMenu(); }, [contextMenu.entityId, hideEntity, closeContextMenu]); const handleShowAll = useCallback(() => { resetVisibilityForHomeFromStore(); closeContextMenu(); }, [closeContextMenu]); const handleSelectSimilar = useCallback(() => { // Use resolvedExpressId (original ID) for IfcDataStore lookups if (!resolvedExpressId || !activeDataStore) { closeContextMenu(); return; } // Get the type of the selected entity const entity = activeDataStore.entities; let entityType: string | null = null; for (let i = 0; i < entity.count; i++) { if (entity.expressId[i] === resolvedExpressId) { entityType = entity.getTypeName(resolvedExpressId); break; } } if (entityType) { // Select all entities of the same type // NOTE: These are original expressIds - for multi-model, should transform to globalIds const sameTypeIds: number[] = []; for (let i = 0; i < entity.count; i++) { if (entity.getTypeName(entity.expressId[i]) === entityType) { sameTypeIds.push(entity.expressId[i]); } } setSelectedEntityIds(sameTypeIds); } closeContextMenu(); }, [resolvedExpressId, activeDataStore, setSelectedEntityIds, closeContextMenu]); const handleSelectSameStorey = useCallback(() => { // Use resolvedExpressId (original ID) for IfcDataStore lookups if (!resolvedExpressId || !activeDataStore?.spatialHierarchy) { closeContextMenu(); return; } const storeyId = activeDataStore.spatialHierarchy.elementToStorey.get(resolvedExpressId); if (storeyId) { const storeyElements = activeDataStore.spatialHierarchy.byStorey.get(storeyId); if (storeyElements) { // NOTE: These are original expressIds - for multi-model, should transform to globalIds setSelectedEntityIds(Array.from(storeyElements)); } } closeContextMenu(); }, [resolvedExpressId, activeDataStore, setSelectedEntityIds, closeContextMenu]); const handleCopyId = useCallback(() => { // Use resolvedExpressId (original ID) for IfcDataStore lookups if (resolvedExpressId && activeDataStore) { const globalId = activeDataStore.entities.getGlobalId(resolvedExpressId); if (globalId) { navigator.clipboard.writeText(globalId); } } closeContextMenu(); }, [resolvedExpressId, activeDataStore, closeContextMenu]); // Right-clicked entity's type — used in the toast message. const contextEntityType = useMemo(() => { if (!resolvedExpressId || !activeDataStore) return ''; return activeDataStore.entities.getTypeName(resolvedExpressId) || ''; }, [resolvedExpressId, activeDataStore]); // Mutation view is required to drive bim.store.* — native-metadata-only // models don't have one, so the Delete option stays hidden there. const canEdit = useMemo(() => { if (!contextEntityRef) return false; return getMutationView(contextEntityRef.modelId) !== null; }, [contextEntityRef, getMutationView]); const handleDuplicate = useCallback( (direction: DuplicateDirection = '+X') => { if (!contextEntityRef || !canEdit) { closeContextMenu(); return; } const result = duplicateEntity(contextEntityRef.modelId, contextEntityRef.expressId, direction); if ('error' in result) { toast.error(`Couldn't duplicate: ${result.error}`); } else { // Move selection onto the new entity so the property panel // refreshes and the user can keep iterating (Cmd+D again // duplicates the duplicate, like a stamp tool). setSelectedEntityId(result.globalId); toast.success(`Duplicated as #${result.expressId} (${direction}) — undo to remove`); } closeContextMenu(); }, [contextEntityRef, canEdit, duplicateEntity, setSelectedEntityId, closeContextMenu], ); const handleDeleteEntity = useCallback(() => { if (!contextEntityRef || !canEdit || !contextMenu.entityId) { closeContextMenu(); return; } const ok = removeEntity(contextEntityRef.modelId, contextEntityRef.expressId); if (ok) { // Tombstoning only affects export — the rendered mesh is still // in the GPU buffers. Hide it via the existing visibility system // so the entity disappears from the scene and stops being // pickable. `Show all` from the empty-space menu restores it // (along with re-running undo to bring back the overlay). hideEntity(contextMenu.entityId); // Drop the selection so the right panel doesn't cling to a // tombstoned id. setSelectedEntityId(null); toast.success(`${contextEntityType || 'Entity'} #${contextEntityRef.expressId} deleted — undo to restore`); } else { toast.error('Delete failed — entity not found in store overlay'); } closeContextMenu(); }, [contextEntityRef, canEdit, contextEntityType, contextMenu.entityId, removeEntity, hideEntity, setSelectedEntityId, closeContextMenu]); // Viewport-constrained placement (mirrors OS context-menu behaviour): // flip up/left when the menu would overflow the bottom/right edges, // then clamp against the opposite edge so it never crosses any side // of the viewport. Two-pass render: invisible first, then measure and // reposition before the browser paints (useLayoutEffect is sync). const [position, setPosition] = useState<{ left: number; top: number } | null>(null); useLayoutEffect(() => { if (!contextMenu.isOpen) { setPosition(null); return; } const node = menuRef.current; if (!node) return; const rect = node.getBoundingClientRect(); const margin = 4; const vw = window.innerWidth; const vh = window.innerHeight; const anchorX = contextMenu.screenX; const anchorY = contextMenu.screenY; // Horizontal: prefer right of cursor, flip to left if it would // overflow the right edge, then clamp. let left = anchorX; if (left + rect.width + margin > vw) { const flipped = anchorX - rect.width; left = flipped >= margin ? flipped : Math.max(margin, vw - rect.width - margin); } if (left < margin) left = margin; // Vertical: prefer below cursor, flip above if it would overflow // the bottom edge, then clamp. let top = anchorY; if (top + rect.height + margin > vh) { const flipped = anchorY - rect.height; top = flipped >= margin ? flipped : Math.max(margin, vh - rect.height - margin); } if (top < margin) top = margin; setPosition({ left, top }); }, [contextMenu.isOpen, contextMenu.screenX, contextMenu.screenY, contextMenu.entityId]); if (!contextMenu.isOpen) { return null; } // Get entity info for display // Use resolvedExpressId (original ID) for IfcDataStore lookups let entityName = ''; let entityType = ''; if (resolvedExpressId && activeDataStore) { entityName = activeDataStore.entities.getName(resolvedExpressId) || ''; entityType = activeDataStore.entities.getTypeName(resolvedExpressId) || ''; } return (
{contextMenu.entityId && ( <> {/* Entity Header */}
{entityName || `${entityType} #${contextMenu.entityId}`}
{entityType}
{/* Basket operations */}
{/* Store-level mutations (bim.store.*). Only surfaced when there's a live mutation view on the model — otherwise these would silently no-op and confuse users. */} {canEdit && ( <>
)} )} {!contextMenu.entityId && ( <> )}
); } /** * Renders extension-contributed entries for the entity or canvas * context-menu slot. Each contribution is `when`-filtered; clicking * dispatches the contributed command through the extension host. */ function ExtensionContextItems({ slot, hasEntity, }: { slot: 'contextMenu.entity' | 'contextMenu.canvas'; hasEntity: boolean; }) { // Loader enriches the contextMenu payload with icon + title from // the linked command (see manifestToContributions). Fall back to // the commandPalette lookup for title if a manifest somehow omits it. const contributions = useSlotContributions(slot); const commandPalette = useSlotContributions('commandPalette'); const host = useOptionalExtensionHost(); const closeContextMenu = useViewerStore((s) => s.closeContextMenu); if (contributions.length === 0) return null; const whenContext = { 'selection.count': hasEntity ? 1 : 0, 'model.loaded': true, }; const titleFor = (c: ResolvedContextMenuContribution): string => { if (c.title) return c.title; const found = commandPalette.find((cp) => cp.payload.id === c.command); return found?.payload.title ?? c.command; }; const visible = contributions.filter((c) => { const when = c.payload.when; if (!when) return true; const parsed = parseWhen(when); if (!parsed.ok) return false; return evaluateWhen(parsed.value, whenContext); }); if (visible.length === 0) return null; return ( <>
{visible.map((c) => { const Icon = resolveExtensionIcon(c.payload.icon); return ( { closeContextMenu(); host?.runCommand(c.payload.command).catch((err) => { toast.error(describeRunCommandError(c.payload.command, err)); }); }} /> ); })} ); } type MenuItemTone = 'default' | 'destructive'; interface MenuItemProps { icon: React.ComponentType<{ className?: string }>; label: string; onClick: () => void; disabled?: boolean; /** Right-aligned keyboard hint (e.g. `'⌘D'`). */ shortcut?: string; /** * Visual tone: * - `default` muted icon, neutral hover * - `destructive` red-toned icon and red-tinted hover (Delete entity) */ tone?: MenuItemTone; } /** * Inline directional duplicate row — primary label on the left * (clickable, fires the default +X duplicate), six axis chips on * the right for explicit direction control. Mirrors the column * placement axes the user already sees on the Raw STEP tab. * * Why six chips and not a sub-menu: a flyout for six options is * wasted real estate, and the chip arrows let the user "see and * pick" in one motion. */ function DuplicateRow({ onDuplicate }: { onDuplicate: (dir: DuplicateDirection) => void }) { return (
onDuplicate('+X')} /> onDuplicate('-X')} /> onDuplicate('+Y')} /> onDuplicate('-Y')} /> onDuplicate('+Z')} /> onDuplicate('-Z')} />
); } function DirectionChip({ dir, label, tooltip, onClick, }: { dir: DuplicateDirection; label: string; tooltip: string; onClick: () => void; }) { return ( ); } function MenuItem({ icon: Icon, label, onClick, disabled, shortcut, tone = 'default' }: MenuItemProps) { const iconClass = tone === 'destructive' ? 'h-4 w-4 text-red-500 dark:text-red-400' : 'h-4 w-4 text-muted-foreground'; const hoverClass = tone === 'destructive' ? 'hover:bg-red-50 dark:hover:bg-red-950/40 hover:text-red-700 dark:hover:text-red-300' : 'hover:bg-muted'; return ( ); }