UNPKG

graph-explorer

Version:

Graph Explorer can be used to explore and RDF graphs in SPARQL endpoints or on the web.

563 lines (492 loc) 15.6 kB
import { Component, createElement, ReactElement, cloneElement } from "react"; import * as ReactDOM from "react-dom"; import * as saveAs from "file-saverjs"; import { LinkRouter, LinkTemplateResolver, TemplateResolver, TypeStyleResolver, } from "../customization/props"; import { MetadataApi } from "../data/metadataApi"; import { ValidationApi } from "../data/validationApi"; import { Rect } from "../diagram/geometry"; import { RestoreGeometry } from "../diagram/commands"; import { CommandHistory, NonRememberingHistory } from "../diagram/history"; import { PaperArea, ZoomOptions, PointerEvent, PointerUpEvent, } from "../diagram/paperArea"; import { DiagramView, IriClickHandler, LabelLanguageSelector, WidgetAttachment, } from "../diagram/view"; import { AsyncModel, GroupBy } from "../editor/asyncModel"; import { AuthoringState } from "../editor/authoringState"; import { EditorController, PropertyEditor } from "../editor/editorController"; import { EventObserver } from "../viewUtils/events"; import { dataURLToBlob } from "../viewUtils/toSvg"; import { PropertySuggestionHandler } from "../widgets/connectionsMenu"; import { SearchCriteria } from "../widgets/instancesSearch"; import { Navigator } from "../widgets/navigator"; import { DefaultToolbar, ToolbarProps } from "./toolbar"; import { WorkspaceMarkup, WorkspaceMarkupProps } from "./workspaceMarkup"; import { WorkspaceEventHandler, WorkspaceEventKey } from "./workspaceContext"; import { forceLayout, applyLayout } from "../viewUtils/layout"; const GRAPH_EXPLORER_WEBSITE = "https://graph-explorer.org/"; const GRAPH_EXPLORER_LOGO_SVG: string | undefined = undefined; export interface WorkspaceProps { /** Saves diagram layout (position and state of elements and links). */ onSaveDiagram?: (workspace: Workspace) => void; /** Persists authored changes in the editor. */ onPersistChanges?: (workspace: Workspace) => void; onPointerDown?: (e: PointerEvent) => void; onPointerMove?: (e: PointerEvent) => void; onPointerUp?: (e: PointerUpEvent) => void; /** * Custom toolbar to replace the default one. */ toolbar?: ReactElement<any>; /** @default false */ hidePanels?: boolean; /** @default false */ hideToolbar?: boolean; /** @default false */ hideScrollBars?: boolean; /** @default false */ hideHalo?: boolean; /** @default true */ hideTutorial?: boolean; /** @default false */ hideNavigator?: boolean; /** @default false */ collapseNavigator?: boolean; /** @default true */ leftPanelInitiallyOpen?: boolean; /** @default false */ rightPanelInitiallyOpen?: boolean; /** * Set of languages to display diagram data. */ languages?: readonly WorkspaceLanguage[]; /** * Currently selected language. */ language?: string; /** * Called when user selected another language from the UI. * * If this function is set, language selection will work in controlled mode; * otherwise language selection will function in uncontrolled mode. */ onLanguageChange?: (language: string) => void; zoomOptions?: ZoomOptions; onZoom?: (scaleX: number, scaleY: number) => void; history?: CommandHistory; viewOptions?: DiagramViewOptions; /** * If provided, switches editor into "authoring mode". */ metadataApi?: MetadataApi; validationApi?: ValidationApi; propertyEditor?: PropertyEditor; onWorkspaceEvent?: WorkspaceEventHandler; /** * Custom panel to search existing elements on the diagram. */ _elementsSearchPanel?: ReactElement<any>; typeStyleResolver?: TypeStyleResolver; linkTemplateResolver?: LinkTemplateResolver; elementTemplateResolver?: TemplateResolver; /** * Overrides label selection based on target language. */ selectLabelLanguage?: LabelLanguageSelector; } export interface DiagramViewOptions { linkRouter?: LinkRouter; onIriClick?: IriClickHandler; groupBy?: GroupBy[]; disableDefaultHalo?: boolean; suggestProperties?: PropertySuggestionHandler; } export interface WorkspaceLanguage { code: string; label: string; } export interface WorkspaceState { readonly criteria?: SearchCriteria; } export class Workspace extends Component<WorkspaceProps, WorkspaceState> { static readonly defaultProps: Partial<WorkspaceProps> = { hideTutorial: true, collapseNavigator: false, leftPanelInitiallyOpen: true, rightPanelInitiallyOpen: false, languages: [ { code: "en", label: "English" }, { code: "ru", label: "Russian" }, ], language: "en", }; private readonly listener = new EventObserver(); private readonly model: AsyncModel; private readonly view: DiagramView; private readonly editor: EditorController; private markup: WorkspaceMarkup; private _watermarkSvg: string | undefined = GRAPH_EXPLORER_LOGO_SVG; private _watermarkUrl: string | undefined = GRAPH_EXPLORER_WEBSITE; constructor(props: WorkspaceProps) { super(props); const { hideHalo, history, viewOptions = {}, metadataApi, validationApi, propertyEditor, elementTemplateResolver, linkTemplateResolver, typeStyleResolver, selectLabelLanguage, } = this.props; const { linkRouter, onIriClick, disableDefaultHalo, suggestProperties, groupBy, } = viewOptions; this.model = new AsyncModel( history || new NonRememberingHistory(), groupBy || [] ); this.view = new DiagramView(this.model, { elementTemplateResolver, linkTemplateResolver, typeStyleResolver, selectLabelLanguage, linkRouter, onIriClick, }); this.editor = new EditorController({ model: this.model, view: this.view, disableHalo: hideHalo || disableDefaultHalo, suggestProperties, validationApi, propertyEditor, }); this.editor.setMetadataApi(metadataApi); this.view.setLanguage(this.props.language); this.state = {}; } _getPaperArea(): PaperArea | undefined { return this.markup ? this.markup.paperArea : undefined; } _setWatermark( watermarkSvg: string | undefined, watermarkUrl: string | undefined ) { this._watermarkSvg = watermarkSvg; this._watermarkUrl = watermarkUrl; this.forceUpdate(); } render(): ReactElement<any> { const { hidePanels, hideToolbar, metadataApi, hideScrollBars, onWorkspaceEvent, _elementsSearchPanel, } = this.props; return createElement(WorkspaceMarkup, { ref: (markup) => { this.markup = markup; }, hidePanels, hideToolbar, hideScrollBars, model: this.model, view: this.view, editor: this.editor, metadataApi, leftPanelInitiallyOpen: this.props.leftPanelInitiallyOpen, rightPanelInitiallyOpen: this.props.rightPanelInitiallyOpen, searchCriteria: this.state.criteria, onSearchCriteriaChanged: (criteria) => this.setState({ criteria }), zoomOptions: this.props.zoomOptions, onZoom: this.props.onZoom, isLeftPanelOpen: this.props.leftPanelInitiallyOpen, isRightPanelOpen: this.props.rightPanelInitiallyOpen, toolbar: createElement(ToolbarWrapper, { workspace: this }), onWorkspaceEvent, watermarkSvg: this._watermarkSvg, watermarkUrl: this._watermarkUrl, elementsSearchPanel: _elementsSearchPanel, } as WorkspaceMarkupProps & React.ClassAttributes<WorkspaceMarkup>); } componentDidMount() { const { onWorkspaceEvent } = this.props; this.editor._initializePaperComponents(this.markup.paperArea); this.updateNavigator(!this.props.hideNavigator); this.listener.listen(this.model.events, "loadingSuccess", () => { this.view.performSyncUpdate(); this.markup.paperArea.centerContent(); }); this.listener.listen( this.model.events, "elementEvent", ({ key: _key, data }) => { if (!data.requestedAddToFilter) { return; } const { source, linkType, direction } = data.requestedAddToFilter; this.setState({ criteria: { refElement: source, refElementLink: linkType, linkDirection: direction, }, }); if (onWorkspaceEvent) { onWorkspaceEvent(WorkspaceEventKey.searchUpdateCriteria); } } ); this.listener.listen(this.markup.paperArea.events, "pointerUp", (e) => { if (this.props.onPointerUp) { this.props.onPointerUp(e); } }); this.listener.listen(this.markup.paperArea.events, "pointerMove", (e) => { if (this.props.onPointerMove) { this.props.onPointerMove(e); } }); this.listener.listen(this.markup.paperArea.events, "pointerDown", (e) => { if (this.props.onPointerDown) { this.props.onPointerDown(e); } }); if (onWorkspaceEvent) { this.listener.listen(this.editor.events, "changeSelection", () => onWorkspaceEvent(WorkspaceEventKey.editorChangeSelection) ); this.listener.listen(this.editor.events, "toggleDialog", () => onWorkspaceEvent(WorkspaceEventKey.editorToggleDialog) ); this.listener.listen(this.editor.events, "addElements", () => onWorkspaceEvent(WorkspaceEventKey.editorAddElements) ); } } componentDidUpdate(nextProps: WorkspaceProps) { const controlledLanguage = Boolean(nextProps.onLanguageChange); if (controlledLanguage && nextProps.language !== this.view.getLanguage()) { this.view.setLanguage(nextProps.language); } if (nextProps.metadataApi !== this.editor.metadataApi) { this.editor.setMetadataApi(nextProps.metadataApi); } if (nextProps.hideNavigator !== this.props.hideNavigator) { this.updateNavigator(!nextProps.hideNavigator); } } componentWillUnmount() { this.listener.stopListening(); this.view.dispose(); } private updateNavigator(showNavigator: boolean) { if (showNavigator) { const widget = createElement(Navigator, { view: this.view, expanded: !this.props.collapseNavigator, }); this.view.setPaperWidget({ key: "navigator", widget, attachment: WidgetAttachment.Viewport, }); } else { this.view.setPaperWidget({ key: "navigator", widget: undefined, attachment: WidgetAttachment.Viewport, }); } } getModel() { return this.model; } getDiagram() { return this.view; } getEditor() { return this.editor; } preventTextSelectionUntilMouseUp() { this.markup.preventTextSelection(); } zoomToFit = () => { this.markup.paperArea.zoomToFit(); }; zoomToFitRect = (bbox: Rect) => { this.markup.paperArea.zoomToFitRect(bbox); }; clearAll = () => { this.editor.removeItems([...this.model.elements]); }; showWaitIndicatorWhile(operation: Promise<any>) { this.editor.setSpinner({}); if (operation) { operation .then(() => { this.editor.setSpinner(undefined); }) .catch((error) => { console.error(error); this.editor.setSpinner({ statusText: "Unknown error occured", errorOccured: true, }); }); } } forceLayout = () => { const batch = this.model.history.startBatch("Force layout"); batch.history.registerToUndo(RestoreGeometry.capture(this.model)); applyLayout(this.model, forceLayout({ model: this.model })); for (const link of this.model.links) { link.setVertices([]); } batch.store(); }; exportSvg = (fileName?: string) => { this.markup.paperArea.exportSVG().then((svg) => { fileName = fileName || "diagram.svg"; const xmlEncodingHeader = '<?xml version="1.0" encoding="UTF-8"?>'; const blob = new Blob([xmlEncodingHeader + svg], { type: "image/svg+xml", }); saveAs(blob, fileName); }); }; exportPng = (fileName?: string) => { fileName = fileName || "diagram.png"; this.markup.paperArea .exportPNG({ backgroundColor: "white" }) .then((dataUri) => { const blob = dataURLToBlob(dataUri); saveAs(blob, fileName); }); }; undo = () => { this.model.history.undo(); }; redo = () => { this.model.history.redo(); }; zoomBy = (value: number) => { this.markup.paperArea.zoomBy(value); }; zoomIn = () => { this.markup.paperArea.zoomIn(); }; zoomOut = () => { this.markup.paperArea.zoomOut(); }; print = () => { this.markup.paperArea.exportSVG().then((svg) => { const printWindow = window.open("", undefined, "width=1280,height=720"); printWindow.document.write(svg); printWindow.document.close(); printWindow.print(); }); }; changeLanguage = (language: string) => { const { onLanguageChange } = this.props; // if language is in controlled mode we'll just forward the change if (onLanguageChange) { onLanguageChange(language); } else { this.view.setLanguage(language); // since we have toolbar dependent on language, we're forcing update here this.forceUpdate(); } }; centerTo = (paperPosition?: { x: number; y: number }) => { this.markup.paperArea.centerTo(paperPosition); }; } interface ToolbarWrapperProps { workspace: Workspace; } class ToolbarWrapper extends Component<ToolbarWrapperProps, {}> { private readonly listener = new EventObserver(); render() { const { workspace } = this.props; const view = workspace.getDiagram(); const editor = workspace.getEditor(); const { languages, onSaveDiagram, onPersistChanges, hidePanels, toolbar } = workspace.props; const canPersistChanges = onPersistChanges ? !AuthoringState.isEmpty(editor.authoringState) : undefined; const canSaveDiagram = !canPersistChanges; const defaultToolbarProps: ToolbarProps = { onZoomIn: workspace.zoomIn, onZoomOut: workspace.zoomOut, onZoomToFit: workspace.zoomToFit, onPrint: workspace.print, onExportSVG: workspace.exportSvg, onExportPNG: workspace.exportPng, canSaveDiagram, onSaveDiagram: onSaveDiagram ? () => onSaveDiagram(workspace) : undefined, canPersistChanges, onPersistChanges: onPersistChanges ? () => onPersistChanges(workspace) : undefined, onForceLayout: () => { workspace.forceLayout(); workspace.getDiagram().performSyncUpdate(); workspace.zoomToFit(); }, onClearAll: workspace.clearAll, languages, selectedLanguage: view.getLanguage(), onChangeLanguage: workspace.changeLanguage, hidePanels, }; if (toolbar) { const toolbarProps: ToolbarProps = { ...defaultToolbarProps, ...toolbar.props, }; return cloneElement(toolbar, toolbarProps); } else { return createElement(DefaultToolbar, defaultToolbarProps); } } componentDidMount() { const { workspace } = this.props; const editor = workspace.getEditor(); this.listener.listen(editor.events, "changeAuthoringState", () => { this.forceUpdate(); }); } componentWillUnmount() { this.listener.stopListening(); } } export function renderTo<WorkspaceComponentProps>( workspace: React.ComponentClass<WorkspaceComponentProps>, container: HTMLElement, props: WorkspaceComponentProps ) { ReactDOM.render(createElement(workspace, props), container); }