/** * Copyright (c) 2025-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { AnchorLinkIcon, CaretDownIcon, CenterFocusIcon, CircleIcon, CloseIcon, ContextMenu, CustomSelectorInput, DescriptionIcon, ControlledDropdownMenu, MenuContent, MenuContentDivider, MenuContentItem, MousePointerIcon, MoveIcon, ShapesIcon, ThinChevronDownIcon, ThinChevronLeftIcon, ThinChevronRightIcon, ThinChevronUpIcon, ZoomInIcon, ZoomOutIcon, clsx, useResizeDetector, createFilter, MarkdownTextViewer, } from '@finos/legend-art'; import { observer } from 'mobx-react-lite'; import { forwardRef, useEffect, useRef } from 'react'; import type { Diagram } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_Diagram.js'; import { type DiagramAnalysisResult, DIAGRAM_INTERACTION_MODE, DIAGRAM_RELATIONSHIP_EDIT_MODE, DIAGRAM_ZOOM_LEVELS, DiagramRenderer, } from './DiagramRenderer.js'; import type { DiagramViewerState } from './DiagramViewerState.js'; import { at } from '@finos/legend-shared'; import { useCommands, type GenericLegendApplicationStore, type NavigationZone, } from '@finos/legend-application'; import type { Class } from '@finos/legend-graph'; enum DIAGRAM_VIEWER_MODES { DIAGRAM_VIEWER = 'diagram-viewer', MODELS_DOCUMENTATION = 'models-documentation', } const DiagramCanvas = observer( forwardRef< HTMLDivElement, { diagramViewerState: DiagramViewerState; diagram: Diagram; actions: { onQueryClass?: ((_class: Class) => void) | undefined; onViewClassDocumentation: (classPath: string) => void; hasClassDocumentation: (classPath: string) => boolean; onGenerateAnchorForActivity: (activity: string) => string; onChangeZone: (zone: NavigationZone, force?: boolean) => void; }; } >(function DiagramCanvas(props, _ref) { const { diagramViewerState, diagram, actions } = props; const { onQueryClass, onViewClassDocumentation, hasClassDocumentation, onGenerateAnchorForActivity, onChangeZone, } = actions; const ref = _ref as React.RefObject; const descriptionText = diagramViewerState.currentDiagram?.description; const { width, height } = useResizeDetector({ refreshMode: 'debounce', refreshRate: 50, targetRef: ref, }); useEffect(() => { diagramViewerState.setExpandDescription(false); }, [diagramViewerState, diagramViewerState.currentDiagram]); useEffect(() => { const renderer = new DiagramRenderer(ref.current, diagram); diagramViewerState.setDiagramRenderer(renderer); diagramViewerState.setupDiagramRenderer(); renderer.render({ initial: true }); }, [ref, diagramViewerState, diagram]); useEffect(() => { if (diagramViewerState.isDiagramRendererInitialized) { diagramViewerState.diagramRenderer.refresh(); } }, [diagramViewerState, width, height]); const queryClass = (): void => { if (onQueryClass && diagramViewerState.contextMenuClassView) { onQueryClass(diagramViewerState.contextMenuClassView.class.value); } }; const viewClassDocumentation = (): void => { if ( diagramViewerState.contextMenuClassView && hasClassDocumentation( diagramViewerState.contextMenuClassView.class.value.path, ) ) { onViewClassDocumentation( diagramViewerState.contextMenuClassView.class.value.path, ); onChangeZone( onGenerateAnchorForActivity( DIAGRAM_VIEWER_MODES.MODELS_DOCUMENTATION, ), ); } }; return ( Query See Model Documentation } disabled={!diagramViewerState.contextMenuClassView} menuProps={{ elevation: 7 }} onClose={(): void => diagramViewerState.setContextMenuClassView(undefined) } > {diagramViewerState.showDescription && (
{diagramViewerState.currentDiagram?.title ? diagramViewerState.currentDiagram.title : 'Untitled'}
{descriptionText ? ( ) : (
(not specified)
)}
)}
); }), ); type DiagramOption = { label: React.ReactNode; value: DiagramAnalysisResult; }; const buildDiagramOption = (diagram: DiagramAnalysisResult): DiagramOption => ({ label: (
{diagram.title ? diagram.title : 'Untitled'}
), value: diagram, }); const DiagramViewerHeader = observer( (props: { applicationStore: GenericLegendApplicationStore; diagramViewerState: DiagramViewerState; actions: { onSyncZoneWithNavigation: (diagram: DiagramAnalysisResult) => void; }; }) => { const { applicationStore, diagramViewerState, actions } = props; const { onSyncZoneWithNavigation } = actions; const diagramOptions = diagramViewerState.diagrams.map(buildDiagramOption); const selectedDiagramOption = diagramViewerState.currentDiagram ? buildDiagramOption(diagramViewerState.currentDiagram) : null; const onDiagramOptionChange = (option: DiagramOption): void => { if (option.value !== diagramViewerState.currentDiagram) { diagramViewerState.setCurrentDiagram(option.value); } }; const diagramFilterOption = createFilter({ ignoreCase: true, ignoreAccents: false, stringify: (option: { data: DiagramOption }) => option.data.value.title, }); const createModeSwitcher = ( editMode: DIAGRAM_INTERACTION_MODE, relationshipMode: DIAGRAM_RELATIONSHIP_EDIT_MODE, ): (() => void) => (): void => diagramViewerState.diagramRenderer.changeMode( editMode, relationshipMode, ); const createCenterZoomer = (zoomLevel: number): (() => void) => (): void => { diagramViewerState.diagramRenderer.zoomCenter(zoomLevel / 100); }; const zoomToFit = (): void => diagramViewerState.diagramRenderer.zoomToFit(); return (
{ const value = parseInt(event.target.value, 10); if ( isNaN(value) || value < 1 || value > diagramViewerState.diagrams.length ) { return; } diagramViewerState.setCurrentDiagram( at(diagramViewerState.diagrams, value - 1), ); }} />
/{diagramViewerState.diagrams.length}
{diagramViewerState.isDiagramRendererInitialized && ( <>
Fit {DIAGRAM_ZOOM_LEVELS.map((zoomLevel) => ( {zoomLevel}% ))} } menuProps={{ anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, transformOrigin: { vertical: 'top', horizontal: 'right' }, elevation: 7, }} >
{Math.round(diagramViewerState.diagramRenderer.zoom * 100)}%
)}
); }, ); export const DiagramPlaceholder: React.FC<{ message: string }> = (props) => (
{props.message}
); export const DiagramViewer = observer( (props: { applicationStore: GenericLegendApplicationStore; diagramViewerState: DiagramViewerState; title?: string | undefined; actions: { onQueryClass?: ((_class: Class) => void) | undefined; onViewClassDocumentation: (classPath: string) => void; hasClassDocumentation: (classPath: string) => boolean; onSyncZoneWithNavigation: (diagram: DiagramAnalysisResult) => void; onGenerateAnchorForActivity: (activity: string) => string; onChangeZone: (zone: NavigationZone, force?: boolean) => void; onSetWikiPageAnchor: (anchorKey: string, element: HTMLElement) => void; onUnsetWikiPageAnchor: (anchorKey: string) => void; }; }) => { const { diagramViewerState, applicationStore, title, actions } = props; const { onSyncZoneWithNavigation, onGenerateAnchorForActivity, onSetWikiPageAnchor, onUnsetWikiPageAnchor, onChangeZone, } = actions; const diagrams = diagramViewerState.diagrams; const sectionRef = useRef(null); const anchor = onGenerateAnchorForActivity( DIAGRAM_VIEWER_MODES.DIAGRAM_VIEWER, ); useCommands(diagramViewerState); useEffect(() => { if (sectionRef.current) { onSetWikiPageAnchor(anchor, sectionRef.current); } return () => onUnsetWikiPageAnchor(anchor); }, [onSetWikiPageAnchor, onUnsetWikiPageAnchor, anchor]); const diagramCanvasRef = useRef(null); const previousDiagram = diagramViewerState.previousDiagram; const nextDiagram = diagramViewerState.nextDiagram; const showPreviousDiagram = (): void => { if (previousDiagram) { diagramViewerState.setCurrentDiagram(previousDiagram); onSyncZoneWithNavigation(previousDiagram); } }; const showNextDiagram = (): void => { if (nextDiagram) { diagramViewerState.setCurrentDiagram(nextDiagram); onSyncZoneWithNavigation(nextDiagram); } }; return (
{title ?? 'Diagrams'}
{diagrams.length > 0 && (
{diagramViewerState.currentDiagram && ( )}
{diagrams.map((diagram) => ( ))}
)} {!diagrams.length && }
); }, );