/** * SchemaElementPopup.tsx * * A popup component that displays LinkML schema element previews when clicking on * class/slot/enum names in the Imports/Exports accordion sections. * * Features: * - Draggable (drag from header) * - Minimizable (collapse to title bar only) * - Resizable (drag edges and corners) * - Shows preview info for classes, slots, and enums * - "Go to" button to navigate to full element view */ import React, { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'; import { linkmlSchemaService } from '../../lib/linkml/linkml-schema-service'; import { isTargetInsideAny } from '../../utils/dom'; import './SchemaElementPopup.css'; export type SchemaElementType = 'class' | 'slot' | 'enum'; interface SchemaElementPopupProps { /** The element name to look up */ elementName: string; /** Type of element */ elementType: SchemaElementType; /** Called when user wants to close the popup */ onClose: () => void; /** Called when user clicks "Go to" button */ onNavigate: (elementName: string, elementType: SchemaElementType) => void; /** Initial position (optional, defaults to center) */ initialPosition?: { x: number; y: number }; } interface Position { x: number; y: number; } interface Size { width: number; height: number; } type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' | null; // Minimum panel dimensions const MIN_WIDTH = 300; const MIN_HEIGHT = 150; const DEFAULT_WIDTH = 400; const DEFAULT_HEIGHT = 320; /** * Unified element info structure */ interface ElementInfo { name: string; type: SchemaElementType; description?: string; uri?: string; parentClass?: string; range?: string; required?: boolean; multivalued?: boolean; slotCount?: number; valueCount?: number; mappings?: { exact?: string[]; close?: string[]; related?: string[]; }; } export const SchemaElementPopup: React.FC = ({ elementName, elementType, onClose, onNavigate, initialPosition, }) => { const [elementInfo, setElementInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Draggable state const [position, setPosition] = useState(initialPosition || null); const [isPositioned, setIsPositioned] = useState(!!initialPosition); const [isDragging, setIsDragging] = useState(false); const dragStartRef = useRef<{ x: number; y: number; posX: number; posY: number } | null>(null); const panelRef = useRef(null); // Minimized state const [isMinimized, setIsMinimized] = useState(false); // Resize state const [size, setSize] = useState({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }); const [isResizing, setIsResizing] = useState(false); const [resizeDirection, setResizeDirection] = useState(null); const resizeStartRef = useRef<{ x: number; y: number; width: number; height: number; posX: number; posY: number; } | null>(null); // Load element information useEffect(() => { const loadInfo = async () => { setLoading(true); setError(null); try { let info: ElementInfo | null = null; if (elementType === 'class') { const semanticInfo = await linkmlSchemaService.getClassSemanticInfo(elementName); if (semanticInfo) { info = { name: semanticInfo.className, type: 'class', description: semanticInfo.description, uri: semanticInfo.classUri, parentClass: semanticInfo.parentClass, slotCount: semanticInfo.slots?.length || 0, mappings: semanticInfo.mappings, }; } } else if (elementType === 'slot') { const slotInfo = await linkmlSchemaService.getSlotInfo(elementName); if (slotInfo) { info = { name: slotInfo.name, type: 'slot', description: slotInfo.description, uri: slotInfo.slotUri, range: slotInfo.range, required: slotInfo.required, multivalued: slotInfo.multivalued, }; } } else if (elementType === 'enum') { const enumInfo = await linkmlSchemaService.getEnumSemanticInfo(elementName); if (enumInfo) { info = { name: enumInfo.enumName, type: 'enum', description: enumInfo.description, // EnumSemanticInfo doesn't have URI - leave undefined valueCount: enumInfo.values?.length || 0, }; } } if (info) { setElementInfo(info); } else { setError(`Could not find ${elementType} "${elementName}"`); } } catch (err) { console.error('[SchemaElementPopup] Error loading element info:', err); setError(`Failed to load: ${err}`); } finally { setLoading(false); } }; loadInfo(); }, [elementName, elementType]); // Center the popup on initial load if no position provided useLayoutEffect(() => { if (!initialPosition && panelRef.current && !position) { const rect = panelRef.current.getBoundingClientRect(); setPosition({ x: (window.innerWidth - rect.width) / 2, y: (window.innerHeight - rect.height) / 3, }); setIsPositioned(true); } }, [initialPosition, position]); // Drag handlers const handleMouseDown = useCallback((e: React.MouseEvent) => { if (isTargetInsideAny(e.target, ['.schema-popup__close', '.schema-popup__minimize', '.schema-popup__navigate'])) { return; } e.preventDefault(); setIsDragging(true); const panel = panelRef.current; if (panel) { const rect = panel.getBoundingClientRect(); const currentX = position?.x ?? rect.left; const currentY = position?.y ?? rect.top; dragStartRef.current = { x: e.clientX, y: e.clientY, posX: currentX, posY: currentY, }; } }, [position]); const handleMouseMove = useCallback((e: MouseEvent) => { if (!isDragging || !dragStartRef.current) return; const deltaX = e.clientX - dragStartRef.current.x; const deltaY = e.clientY - dragStartRef.current.y; const newX = dragStartRef.current.posX + deltaX; const newY = dragStartRef.current.posY + deltaY; const maxX = window.innerWidth - 100; const maxY = window.innerHeight - 50; setPosition({ x: Math.max(0, Math.min(newX, maxX)), y: Math.max(0, Math.min(newY, maxY)), }); }, [isDragging]); const handleMouseUp = useCallback(() => { setIsDragging(false); dragStartRef.current = null; }, []); // Add/remove global mouse event listeners for dragging useEffect(() => { if (isDragging) { window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); document.body.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; } else { document.body.style.cursor = ''; document.body.style.userSelect = ''; } return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; }, [isDragging, handleMouseMove, handleMouseUp]); // Toggle minimize const toggleMinimize = useCallback(() => { setIsMinimized(prev => !prev); }, []); // Resize handlers const handleResizeMouseDown = useCallback((direction: ResizeDirection) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setIsResizing(true); setResizeDirection(direction); const panel = panelRef.current; if (panel) { const rect = panel.getBoundingClientRect(); resizeStartRef.current = { x: e.clientX, y: e.clientY, width: size.width, height: size.height, posX: position?.x ?? rect.left, posY: position?.y ?? rect.top, }; } }, [size, position]); const handleResizeMouseMove = useCallback((e: MouseEvent) => { if (!isResizing || !resizeStartRef.current || !resizeDirection) return; const deltaX = e.clientX - resizeStartRef.current.x; const deltaY = e.clientY - resizeStartRef.current.y; let newWidth = resizeStartRef.current.width; let newHeight = resizeStartRef.current.height; let newX = resizeStartRef.current.posX; let newY = resizeStartRef.current.posY; if (resizeDirection.includes('e')) { newWidth = Math.max(MIN_WIDTH, resizeStartRef.current.width + deltaX); } if (resizeDirection.includes('w')) { const potentialWidth = resizeStartRef.current.width - deltaX; if (potentialWidth >= MIN_WIDTH) { newWidth = potentialWidth; newX = resizeStartRef.current.posX + deltaX; } } if (resizeDirection.includes('s')) { newHeight = Math.max(MIN_HEIGHT, resizeStartRef.current.height + deltaY); } if (resizeDirection.includes('n')) { const potentialHeight = resizeStartRef.current.height - deltaY; if (potentialHeight >= MIN_HEIGHT) { newHeight = potentialHeight; newY = resizeStartRef.current.posY + deltaY; } } setSize({ width: newWidth, height: newHeight }); if (resizeDirection.includes('w') || resizeDirection.includes('n')) { setPosition({ x: newX, y: newY }); } }, [isResizing, resizeDirection]); const handleResizeMouseUp = useCallback(() => { setIsResizing(false); setResizeDirection(null); resizeStartRef.current = null; }, []); // Add/remove global mouse event listeners for resizing useEffect(() => { if (isResizing) { window.addEventListener('mousemove', handleResizeMouseMove); window.addEventListener('mouseup', handleResizeMouseUp); document.body.style.userSelect = 'none'; } return () => { window.removeEventListener('mousemove', handleResizeMouseMove); window.removeEventListener('mouseup', handleResizeMouseUp); if (!isDragging) { document.body.style.userSelect = ''; } }; }, [isResizing, handleResizeMouseMove, handleResizeMouseUp, isDragging]); // Handle navigate button click const handleNavigate = useCallback(() => { onNavigate(elementName, elementType); onClose(); }, [elementName, elementType, onNavigate, onClose]); // Render type badge const renderTypeBadge = (type: SchemaElementType) => { const colors: Record = { 'class': '#3B82F6', // Blue 'slot': '#10B981', // Green 'enum': '#F59E0B', // Amber }; return ( {type} ); }; // Calculate panel style const panelStyle: React.CSSProperties = { width: size.width, height: isMinimized ? 'auto' : size.height, visibility: isPositioned ? 'visible' : 'hidden', ...(position && { position: 'fixed', left: position.x, top: position.y, }), }; return (
{/* Header */}
{elementName} {renderTypeBadge(elementType)}
{/* Content */} {!isMinimized && (
{loading && (
Loading {elementType} information...
)} {error && (
{error}
)} {!loading && !error && elementInfo && ( <> {/* Description */} {elementInfo.description && (
Description

{elementInfo.description}

)} {/* URI */} {elementInfo.uri && (
URI {elementInfo.uri}
)} {/* Parent class (for classes) */} {elementInfo.parentClass && (
Parent Class {elementInfo.parentClass}
)} {/* Range (for slots) */} {elementInfo.range && (
Range {elementInfo.range}
)} {/* Properties (for slots) */} {elementInfo.type === 'slot' && (
{elementInfo.required !== undefined && ( {elementInfo.required ? 'required' : 'optional'} )} {elementInfo.multivalued !== undefined && ( {elementInfo.multivalued ? 'multivalued' : 'single-valued'} )}
)} {/* Slot count (for classes) */} {elementInfo.slotCount !== undefined && elementInfo.slotCount > 0 && (
Slots {elementInfo.slotCount} slots
)} {/* Value count (for enums) */} {elementInfo.valueCount !== undefined && elementInfo.valueCount > 0 && (
Values {elementInfo.valueCount} permissible values
)} {/* Mappings (summary) */} {elementInfo.mappings && ( (elementInfo.mappings.exact?.length || elementInfo.mappings.close?.length || elementInfo.mappings.related?.length) ? (
Mappings
{elementInfo.mappings.exact?.length ? ( {elementInfo.mappings.exact.length} exact ) : null} {elementInfo.mappings.close?.length ? ( {elementInfo.mappings.close.length} close ) : null} {elementInfo.mappings.related?.length ? ( {elementInfo.mappings.related.length} related ) : null}
) : null )} {/* Navigate button */}
)}
)} {/* Resize handles */} {!isMinimized && ( <>
)}
); }; export default SchemaElementPopup;