- 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.
549 lines
18 KiB
TypeScript
549 lines
18 KiB
TypeScript
/**
|
||
* 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<SchemaElementPopupProps> = ({
|
||
elementName,
|
||
elementType,
|
||
onClose,
|
||
onNavigate,
|
||
initialPosition,
|
||
}) => {
|
||
const [elementInfo, setElementInfo] = useState<ElementInfo | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Draggable state
|
||
const [position, setPosition] = useState<Position | null>(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<HTMLDivElement>(null);
|
||
|
||
// Minimized state
|
||
const [isMinimized, setIsMinimized] = useState(false);
|
||
|
||
// Resize state
|
||
const [size, setSize] = useState<Size>({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT });
|
||
const [isResizing, setIsResizing] = useState(false);
|
||
const [resizeDirection, setResizeDirection] = useState<ResizeDirection>(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<SchemaElementType, string> = {
|
||
'class': '#3B82F6', // Blue
|
||
'slot': '#10B981', // Green
|
||
'enum': '#F59E0B', // Amber
|
||
};
|
||
return (
|
||
<span
|
||
className="schema-popup__type-badge"
|
||
style={{ backgroundColor: colors[type] }}
|
||
>
|
||
{type}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
// 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 (
|
||
<div
|
||
ref={panelRef}
|
||
className={`schema-popup ${isMinimized ? 'schema-popup--minimized' : ''} ${isDragging ? 'schema-popup--dragging' : ''}`}
|
||
style={panelStyle}
|
||
>
|
||
{/* Header */}
|
||
<div
|
||
className="schema-popup__header"
|
||
onMouseDown={handleMouseDown}
|
||
>
|
||
<div className="schema-popup__title">
|
||
<code className="schema-popup__name">{elementName}</code>
|
||
{renderTypeBadge(elementType)}
|
||
</div>
|
||
<div className="schema-popup__controls">
|
||
<button
|
||
className="schema-popup__minimize"
|
||
onClick={toggleMinimize}
|
||
title={isMinimized ? 'Expand' : 'Minimize'}
|
||
>
|
||
{isMinimized ? '□' : '−'}
|
||
</button>
|
||
<button
|
||
className="schema-popup__close"
|
||
onClick={onClose}
|
||
title="Close"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
{!isMinimized && (
|
||
<div className="schema-popup__content">
|
||
{loading && (
|
||
<div className="schema-popup__loading">
|
||
Loading {elementType} information...
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="schema-popup__error">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{!loading && !error && elementInfo && (
|
||
<>
|
||
{/* Description */}
|
||
{elementInfo.description && (
|
||
<div className="schema-popup__section">
|
||
<span className="schema-popup__section-label">Description</span>
|
||
<p className="schema-popup__description">{elementInfo.description}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* URI */}
|
||
{elementInfo.uri && (
|
||
<div className="schema-popup__section">
|
||
<span className="schema-popup__section-label">URI</span>
|
||
<code className="schema-popup__uri">{elementInfo.uri}</code>
|
||
</div>
|
||
)}
|
||
|
||
{/* Parent class (for classes) */}
|
||
{elementInfo.parentClass && (
|
||
<div className="schema-popup__section">
|
||
<span className="schema-popup__section-label">Parent Class</span>
|
||
<code className="schema-popup__value">{elementInfo.parentClass}</code>
|
||
</div>
|
||
)}
|
||
|
||
{/* Range (for slots) */}
|
||
{elementInfo.range && (
|
||
<div className="schema-popup__section">
|
||
<span className="schema-popup__section-label">Range</span>
|
||
<code className="schema-popup__value">{elementInfo.range}</code>
|
||
</div>
|
||
)}
|
||
|
||
{/* Properties (for slots) */}
|
||
{elementInfo.type === 'slot' && (
|
||
<div className="schema-popup__section schema-popup__properties">
|
||
{elementInfo.required !== undefined && (
|
||
<span className={`schema-popup__prop ${elementInfo.required ? 'schema-popup__prop--true' : ''}`}>
|
||
{elementInfo.required ? 'required' : 'optional'}
|
||
</span>
|
||
)}
|
||
{elementInfo.multivalued !== undefined && (
|
||
<span className={`schema-popup__prop ${elementInfo.multivalued ? 'schema-popup__prop--true' : ''}`}>
|
||
{elementInfo.multivalued ? 'multivalued' : 'single-valued'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Slot count (for classes) */}
|
||
{elementInfo.slotCount !== undefined && elementInfo.slotCount > 0 && (
|
||
<div className="schema-popup__section">
|
||
<span className="schema-popup__section-label">Slots</span>
|
||
<span className="schema-popup__count">{elementInfo.slotCount} slots</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Value count (for enums) */}
|
||
{elementInfo.valueCount !== undefined && elementInfo.valueCount > 0 && (
|
||
<div className="schema-popup__section">
|
||
<span className="schema-popup__section-label">Values</span>
|
||
<span className="schema-popup__count">{elementInfo.valueCount} permissible values</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Mappings (summary) */}
|
||
{elementInfo.mappings && (
|
||
(elementInfo.mappings.exact?.length ||
|
||
elementInfo.mappings.close?.length ||
|
||
elementInfo.mappings.related?.length) ? (
|
||
<div className="schema-popup__section">
|
||
<span className="schema-popup__section-label">Mappings</span>
|
||
<div className="schema-popup__mappings">
|
||
{elementInfo.mappings.exact?.length ? (
|
||
<span className="schema-popup__mapping-badge schema-popup__mapping-badge--exact">
|
||
{elementInfo.mappings.exact.length} exact
|
||
</span>
|
||
) : null}
|
||
{elementInfo.mappings.close?.length ? (
|
||
<span className="schema-popup__mapping-badge schema-popup__mapping-badge--close">
|
||
{elementInfo.mappings.close.length} close
|
||
</span>
|
||
) : null}
|
||
{elementInfo.mappings.related?.length ? (
|
||
<span className="schema-popup__mapping-badge schema-popup__mapping-badge--related">
|
||
{elementInfo.mappings.related.length} related
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null
|
||
)}
|
||
|
||
{/* Navigate button */}
|
||
<div className="schema-popup__actions">
|
||
<button
|
||
className="schema-popup__navigate"
|
||
onClick={handleNavigate}
|
||
>
|
||
Go to {elementType} →
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Resize handles */}
|
||
{!isMinimized && (
|
||
<>
|
||
<div className="schema-popup__resize schema-popup__resize--n" onMouseDown={handleResizeMouseDown('n')} />
|
||
<div className="schema-popup__resize schema-popup__resize--s" onMouseDown={handleResizeMouseDown('s')} />
|
||
<div className="schema-popup__resize schema-popup__resize--e" onMouseDown={handleResizeMouseDown('e')} />
|
||
<div className="schema-popup__resize schema-popup__resize--w" onMouseDown={handleResizeMouseDown('w')} />
|
||
<div className="schema-popup__resize schema-popup__resize--ne" onMouseDown={handleResizeMouseDown('ne')} />
|
||
<div className="schema-popup__resize schema-popup__resize--nw" onMouseDown={handleResizeMouseDown('nw')} />
|
||
<div className="schema-popup__resize schema-popup__resize--se" onMouseDown={handleResizeMouseDown('se')} />
|
||
<div className="schema-popup__resize schema-popup__resize--sw" onMouseDown={handleResizeMouseDown('sw')} />
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SchemaElementPopup;
|