From 7c7d8c0270eb811ca62d8d5b8dc0eddf30db7522 Mon Sep 17 00:00:00 2001 From: kempersc Date: Wed, 14 Jan 2026 15:13:06 +0100 Subject: [PATCH] feat: Add SchemaElementPopup component for displaying LinkML schema element previews - Implemented a draggable, resizable, and minimizable popup component for displaying previews of LinkML schema elements (classes, slots, enums). - Integrated loading states and error handling for fetching element information. - Added navigation functionality to go to full element view. - Enhanced user experience with type badges and detailed descriptions for each element type. chore: Migrate AudioEventSegment, BayNumber, BoxNumber, and BudgetStatus classes to new YAML schema format - Created new YAML definitions for AudioEventSegment, BayNumber, BoxNumber, and BudgetStatus classes with detailed descriptions and attributes. - Migrated from deprecated slots to new class structures as part of Rule 53. - Updated imports and prefixes for consistency across schemas. chore: Archive deprecated slots for audio_event_segments, bay_number, and box_number - Archived previous slot definitions for audio_event_segments, bay_number, and box_number to maintain historical records. - Updated slot descriptions and ensured proper URI mappings for future reference. --- .../schemas/20251121/linkml/manifest.json | 2 +- .../linkml/modules/slots/slot_fixes.yaml | 23 + .../components/linkml/SchemaElementPopup.css | 479 +++++++++++++++ .../components/linkml/SchemaElementPopup.tsx | 549 ++++++++++++++++++ frontend/src/pages/LinkMLViewerPage.tsx | 42 +- schemas/20251121/linkml/manifest.json | 2 +- .../modules/classes/AudioEventSegment.yaml | 172 ++++++ .../linkml/modules/classes/BayNumber.yaml | 92 +++ .../linkml/modules/classes/BoxNumber.yaml | 97 ++++ .../linkml/modules/classes/Budget.yaml | 38 +- .../linkml/modules/classes/BudgetStatus.yaml | 105 ++++ .../linkml/modules/classes/StorageUnit.yaml | 41 +- .../modules/classes/VideoAudioAnnotation.yaml | 23 +- ...dio_event_segments_archived_20260114.yaml} | 0 .../bay_number_archived_20260114.yaml} | 0 .../box_number_archived_20260114.yaml} | 0 .../linkml/modules/slots/slot_fixes.yaml | 36 +- 17 files changed, 1652 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/linkml/SchemaElementPopup.css create mode 100644 frontend/src/components/linkml/SchemaElementPopup.tsx create mode 100644 schemas/20251121/linkml/modules/classes/AudioEventSegment.yaml create mode 100644 schemas/20251121/linkml/modules/classes/BayNumber.yaml create mode 100644 schemas/20251121/linkml/modules/classes/BoxNumber.yaml create mode 100644 schemas/20251121/linkml/modules/classes/BudgetStatus.yaml rename schemas/20251121/linkml/modules/slots/{audio_event_segments.yaml => archive/audio_event_segments_archived_20260114.yaml} (100%) rename schemas/20251121/linkml/modules/slots/{bay_number.yaml => archive/bay_number_archived_20260114.yaml} (100%) rename schemas/20251121/linkml/modules/slots/{box_number.yaml => archive/box_number_archived_20260114.yaml} (100%) diff --git a/frontend/public/schemas/20251121/linkml/manifest.json b/frontend/public/schemas/20251121/linkml/manifest.json index 53f0df4d13..64ab71ea07 100644 --- a/frontend/public/schemas/20251121/linkml/manifest.json +++ b/frontend/public/schemas/20251121/linkml/manifest.json @@ -1,5 +1,5 @@ { - "generated": "2026-01-14T12:29:52.423Z", + "generated": "2026-01-14T14:05:38.322Z", "schemaRoot": "/schemas/20251121/linkml", "totalFiles": 2884, "categoryCounts": { diff --git a/frontend/public/schemas/20251121/linkml/modules/slots/slot_fixes.yaml b/frontend/public/schemas/20251121/linkml/modules/slots/slot_fixes.yaml index 32539a7d11..84ab114678 100644 --- a/frontend/public/schemas/20251121/linkml/modules/slots/slot_fixes.yaml +++ b/frontend/public/schemas/20251121/linkml/modules/slots/slot_fixes.yaml @@ -1316,3 +1316,26 @@ fixes: type: slot - label: TimeSpan type: class + - original_slot_id: https://nde.nl/ontology/hc/slot/xpath_matched_text + revision: + - label: has_or_had_text + type: slot + - label: TextSegment + type: class + - original_slot_id: https://nde.nl/ontology/hc/slot/xpath_match_score + revision: + - label: has_or_had_score + type: slot + - label: XPathScore + type: class + - original_slot_id: https://nde.nl/ontology/hc/slot/xpath + revision: + - label: has_or_had_provenance + type: slot + - label: Provenance + type: class + - label: has_or_had_provenance_path + type: slot + - label: XPath + type: class + - diff --git a/frontend/src/components/linkml/SchemaElementPopup.css b/frontend/src/components/linkml/SchemaElementPopup.css new file mode 100644 index 0000000000..9356bc4298 --- /dev/null +++ b/frontend/src/components/linkml/SchemaElementPopup.css @@ -0,0 +1,479 @@ +/** + * SchemaElementPopup.css + * + * Styles for the schema element popup shown when clicking on class/slot/enum + * names in the Imports/Exports accordion sections in the LinkML viewer. + */ + +.schema-popup { + position: fixed; + width: 400px; + height: 320px; + max-height: calc(100vh - 100px); + background: white; + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 10200; /* Above SemanticDetailsPanel (10100) */ + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid #e5e7eb; + transition: box-shadow 0.2s; +} + +/* Dragging state */ +.schema-popup--dragging { + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25), 0 8px 20px rgba(0, 0, 0, 0.15); + opacity: 0.95; +} + +/* Minimized state */ +.schema-popup--minimized { + height: auto !important; + max-height: 52px; + overflow: hidden; +} + +.schema-popup--minimized .schema-popup__header { + border-bottom: none; +} + +/* Header - default blue for classes */ +.schema-popup__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%); + color: #ffffff; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + cursor: grab; + flex-shrink: 0; +} + +.schema-popup--dragging .schema-popup__header { + cursor: grabbing; +} + +.schema-popup__title { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.schema-popup__name { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; + font-size: 14px; + font-weight: 600; + color: #ffffff; + background: rgba(255, 255, 255, 0.15); + padding: 3px 8px; + border-radius: 4px; +} + +.schema-popup__controls { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.schema-popup__minimize, +.schema-popup__close { + background: rgba(255, 255, 255, 0.1); + border: none; + color: white; + width: 28px; + height: 28px; + border-radius: 6px; + cursor: pointer; + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.schema-popup__minimize:hover, +.schema-popup__close:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* Type badge */ +.schema-popup__type-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 12px; + font-size: 10px; + font-weight: 600; + color: white; + text-transform: uppercase; + letter-spacing: 0.03em; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +/* Content */ +.schema-popup__content { + padding: 16px; + overflow-y: auto; + overscroll-behavior: contain; + flex: 1; +} + +.schema-popup__loading, +.schema-popup__error { + padding: 24px 16px; + text-align: center; + color: #64748b; +} + +.schema-popup__error { + color: #dc2626; + background: #fef2f2; + border-radius: 8px; + margin: 8px; +} + +/* Sections */ +.schema-popup__section { + margin-bottom: 14px; + padding-bottom: 14px; + border-bottom: 1px solid #f1f5f9; +} + +.schema-popup__section:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.schema-popup__section-label { + font-size: 11px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + display: block; + margin-bottom: 6px; +} + +.schema-popup__description { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: #475569; +} + +/* URI display */ +.schema-popup__uri { + display: block; + background: #f1f5f9; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + color: #475569; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; + word-break: break-all; +} + +/* Values */ +.schema-popup__value { + display: inline-block; + background: #f1f5f9; + padding: 2px 8px; + border-radius: 4px; + font-size: 13px; + color: #1e40af; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; +} + +.schema-popup__count { + font-size: 13px; + color: #475569; +} + +/* Properties (for slots) */ +.schema-popup__properties { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.schema-popup__prop { + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + background: #f1f5f9; + color: #64748b; +} + +.schema-popup__prop--true { + background: #dcfce7; + color: #166534; +} + +/* Mappings summary */ +.schema-popup__mappings { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.schema-popup__mapping-badge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} + +.schema-popup__mapping-badge--exact { + background: #dcfce7; + color: #166534; +} + +.schema-popup__mapping-badge--close { + background: #dbeafe; + color: #1e40af; +} + +.schema-popup__mapping-badge--related { + background: #fef3c7; + color: #92400e; +} + +/* Actions */ +.schema-popup__actions { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid #f1f5f9; +} + +.schema-popup__navigate { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%); + color: white; + font-size: 13px; + font-weight: 500; + text-decoration: none; + border: none; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s, transform 0.1s; +} + +.schema-popup__navigate:hover { + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + transform: translateY(-1px); +} + +/* Resize handles */ +.schema-popup__resize { + position: absolute; + z-index: 10; +} + +.schema-popup__resize--n { + top: 0; + left: 8px; + right: 8px; + height: 6px; + cursor: n-resize; +} + +.schema-popup__resize--s { + bottom: 0; + left: 8px; + right: 8px; + height: 6px; + cursor: s-resize; +} + +.schema-popup__resize--e { + right: 0; + top: 8px; + bottom: 8px; + width: 6px; + cursor: e-resize; +} + +.schema-popup__resize--w { + left: 0; + top: 8px; + bottom: 8px; + width: 6px; + cursor: w-resize; +} + +.schema-popup__resize--ne { + top: 0; + right: 0; + width: 12px; + height: 12px; + cursor: ne-resize; +} + +.schema-popup__resize--nw { + top: 0; + left: 0; + width: 12px; + height: 12px; + cursor: nw-resize; +} + +.schema-popup__resize--se { + bottom: 0; + right: 0; + width: 12px; + height: 12px; + cursor: se-resize; +} + +.schema-popup__resize--sw { + bottom: 0; + left: 0; + width: 12px; + height: 12px; + cursor: sw-resize; +} + +/* SE corner grip indicator */ +.schema-popup__resize--se::after { + content: ''; + position: absolute; + right: 3px; + bottom: 3px; + width: 8px; + height: 8px; + border-right: 2px solid #94a3b8; + border-bottom: 2px solid #94a3b8; + opacity: 0.5; + transition: opacity 0.2s; +} + +.schema-popup__resize--se:hover::after { + opacity: 1; +} + +/* Dark mode */ +[data-theme="dark"] .schema-popup { + background: #1e293b; + border-color: #334155; +} + +[data-theme="dark"] .schema-popup__header { + background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 100%); +} + +[data-theme="dark"] .schema-popup__content { + background: #1e293b; +} + +[data-theme="dark"] .schema-popup__section { + border-bottom-color: #334155; +} + +[data-theme="dark"] .schema-popup__section-label { + color: #94a3b8; +} + +[data-theme="dark"] .schema-popup__description { + color: #cbd5e1; +} + +[data-theme="dark"] .schema-popup__uri { + background: #334155; + color: #e2e8f0; +} + +[data-theme="dark"] .schema-popup__value { + background: #334155; + color: #93c5fd; +} + +[data-theme="dark"] .schema-popup__count { + color: #94a3b8; +} + +[data-theme="dark"] .schema-popup__prop { + background: #334155; + color: #94a3b8; +} + +[data-theme="dark"] .schema-popup__prop--true { + background: rgba(34, 197, 94, 0.15); + color: #86efac; +} + +[data-theme="dark"] .schema-popup__mapping-badge--exact { + background: rgba(34, 197, 94, 0.15); + color: #86efac; +} + +[data-theme="dark"] .schema-popup__mapping-badge--close { + background: rgba(59, 130, 246, 0.15); + color: #93c5fd; +} + +[data-theme="dark"] .schema-popup__mapping-badge--related { + background: rgba(245, 158, 11, 0.15); + color: #fcd34d; +} + +[data-theme="dark"] .schema-popup__actions { + border-top-color: #334155; +} + +[data-theme="dark"] .schema-popup__error { + background: rgba(220, 38, 38, 0.15); + color: #fca5a5; +} + +[data-theme="dark"] .schema-popup__loading { + color: #94a3b8; +} + +[data-theme="dark"] .schema-popup__resize--se::after { + border-color: #64748b; +} + +/* Responsive - Full screen on mobile */ +@media (max-width: 600px) { + .schema-popup { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100% !important; + height: 100% !important; + max-height: 100vh !important; + border-radius: 0; + } + + .schema-popup__header { + cursor: default; + } + + .schema-popup__resize { + display: none; + } + + .schema-popup__minimize { + display: none; + } + + .schema-popup__close { + width: 40px; + height: 40px; + font-size: 24px; + } +} diff --git a/frontend/src/components/linkml/SchemaElementPopup.tsx b/frontend/src/components/linkml/SchemaElementPopup.tsx new file mode 100644 index 0000000000..f6fdcd186c --- /dev/null +++ b/frontend/src/components/linkml/SchemaElementPopup.tsx @@ -0,0 +1,549 @@ +/** + * 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; diff --git a/frontend/src/pages/LinkMLViewerPage.tsx b/frontend/src/pages/LinkMLViewerPage.tsx index c28ba144ec..ba9d40c4c6 100644 --- a/frontend/src/pages/LinkMLViewerPage.tsx +++ b/frontend/src/pages/LinkMLViewerPage.tsx @@ -46,6 +46,7 @@ import { isUniversalElement, } from '../lib/schema-custodian-mapping'; import { OntologyTermPopup } from '../components/ontology/OntologyTermPopup'; +import { SchemaElementPopup, type SchemaElementType } from '../components/linkml/SchemaElementPopup'; import './LinkMLViewerPage.css'; /** @@ -1299,6 +1300,9 @@ const LinkMLViewerPage: React.FC = () => { // State for ontology term popup (shows when clicking mapping tags like rico:Rule) const [ontologyPopupCurie, setOntologyPopupCurie] = useState(null); + // State for schema element popup (shows when clicking class/slot/enum links in Imports/Exports sections) + const [schemaElementPopup, setSchemaElementPopup] = useState<{ name: string; type: SchemaElementType } | null>(null); + // Sync custodian filter to URL params useEffect(() => { const currentParam = searchParams.get('custodian'); @@ -1835,6 +1839,18 @@ const LinkMLViewerPage: React.FC = () => { } }, [categories, setSearchParams]); + // Handler for when user clicks "Go to" in the schema element popup + const handleSchemaElementNavigate = useCallback((elementName: string, elementType: SchemaElementType) => { + if (elementType === 'class') { + navigateToClass(elementName); + } else if (elementType === 'enum') { + navigateToEnum(elementName); + } else if (elementType === 'slot') { + navigateToSlot(elementName); + } + setSchemaElementPopup(null); + }, [navigateToClass, navigateToEnum, navigateToSlot]); + // Track if initialization has already happened (prevents re-init on URL param changes) const isInitializedRef = useRef(false); @@ -2470,7 +2486,7 @@ const LinkMLViewerPage: React.FC = () => {
@@ -2486,7 +2502,7 @@ const LinkMLViewerPage: React.FC = () => { @@ -3191,11 +3207,10 @@ const LinkMLViewerPage: React.FC = () => { className="linkml-viewer__imports-link" onClick={() => { const range = slotImports[slot.name].rangeType!; - if (range.isClass) { - navigateToClass(range.name); - } else if (range.isEnum) { - navigateToEnum(range.name); - } + setSchemaElementPopup({ + name: range.name, + type: range.isClass ? 'class' : 'enum' + }); }} > {slotImports[slot.name].rangeType!.name} @@ -3214,11 +3229,10 @@ const LinkMLViewerPage: React.FC = () => { key={type.name} className="linkml-viewer__imports-link" onClick={() => { - if (type.isClass) { - navigateToClass(type.name); - } else if (type.isEnum) { - navigateToEnum(type.name); - } + setSchemaElementPopup({ + name: type.name, + type: type.isClass ? 'class' : 'enum' + }); }} > {type.name} @@ -3267,7 +3281,7 @@ const LinkMLViewerPage: React.FC = () => { @@ -3284,7 +3298,7 @@ const LinkMLViewerPage: React.FC = () => {