import * as React from 'react' ; import { LinkModel } from '../data/model' ; import { Vector , computePolyline , getPointAlongPolyline , computePolylineLength } from '../diagram/geometry' ; import { TransformedSvgCanvas } from '../diagram/paper' ; import { PaperWidgetProps } from '../diagram/paperArea' ; import { DiagramView , RenderingLayer } from '../diagram/view' ; import { Link } from '../diagram/elements' ; import { Debouncer } from '../viewUtils/async' ; import { EventObserver } from '../viewUtils/events' ; import { HtmlSpinner } from '../viewUtils/spinner' ; import { EditorController } from './editorController' ; import { AuthoringKind , AuthoringState } from './authoringState' ; import { LinkValidation , ElementValidation } from './validation' ; export interface Props extends PaperWidgetProps { view : DiagramView ; editor : EditorController ; } const CLASS_NAME = `ontodia-authoring-state` ; const LINK_LABEL_MARGIN = 5 ; export class LinkStateWidget extends React . Component < Props , { } > { private readonly listener = new EventObserver ( ) ; private readonly delayedUpdate = new Debouncer ( ) ; componentDidMount ( ) { this . listenEvents ( ) ; } componentDidUpdate ( prevProps ) { const sameEventSources = ( this . props . view === prevProps . view ) ; if ( ! sameEventSources ) { this . listener . stopListening ( ) ; this . listenEvents ( ) ; } } componentWillUnmount ( ) { this . listener . stopListening ( ) ; } private listenEvents ( ) { const { editor , view } = this . props ; this . listener . listen ( editor . model . events , 'elementEvent' , ( { data } ) => { if ( data . changeSize || data . changePosition ) { this . scheduleUpdate ( ) ; } } ) ; this . listener . listen ( editor . model . events , 'linkEvent' , ( { data } ) => { if ( data . changeVertices || data . changeLabelBounds ) { this . scheduleUpdate ( ) ; } } ) ; this . listener . listen ( editor . model . events , 'changeCells' , this . scheduleUpdate ) ; this . listener . listen ( editor . events , 'changeAuthoringState' , this . scheduleUpdate ) ; this . listener . listen ( editor . events , 'changeTemporaryState' , this . scheduleUpdate ) ; this . listener . listen ( editor . events , 'changeValidationState' , this . scheduleUpdate ) ; this . listener . listen ( view . events , 'syncUpdate' , ( { layer } ) => { if ( layer === RenderingLayer . Editor ) { this . delayedUpdate . runSynchronously ( ) ; } } ) ; } private scheduleUpdate = ( ) => { this . delayedUpdate . call ( this . performUpdate ) ; } ; private performUpdate = ( ) => { this . forceUpdate ( ) ; } ; private calculateLinkPath ( link ) { const polyline = this . calculatePolyline ( link ) ; return 'M' + polyline . map ( ( { x , y } ) => `${ x },${ y }` ) . join ( ' L' ) ; } private calculatePolyline ( link ) { const { editor , view } = this . props ; const source = editor . model . getElement ( link . sourceId ) ; const target = editor . model . getElement ( link . targetId ) ; const route = view . getRouting ( link . id ) ; const verticesDefinedByUser = link . vertices || [ ] ; const vertices = route ? route . vertices : verticesDefinedByUser ; return computePolyline ( source , target , vertices ) ; } private renderLinkStateLabels ( ) { const { editor } = this . props ; return editor . model . links . map ( link => { let renderedState = null ; const state = editor . authoringState . links . get ( link . data ) ; if ( state ) { const onCancel = ( ) => editor . discardChange ( state ) ; let statusText ; let title ; if ( state . deleted ) { statusText = 'Delete' ; title = 'Revert deletion of the link' ; } else if ( ! state . before ) { statusText = 'New' ; title = 'Revert creation of the link' ; } else { statusText = 'Change' ; title = 'Revert all changes in properties of the link' ; } if ( statusText && title ) { renderedState = ( < span > < span className = { `${ CLASS_NAME }__state-label` } > { statusText } < /span> [ < span className = { `${ CLASS_NAME }__state-cancel` } onClick = { onCancel } title = { title } > cancel < /span>] < /span>); } } const renderedErrors = this . renderLinkErrors ( link . data ) ; if ( renderedState || renderedErrors ) { const labelPosition = this . getLinkStateLabelPosition ( link ) ; if ( ! labelPosition ) { return null ; } const style = { left : labelPosition . x , top : labelPosition . y } ; return < div className = { `${ CLASS_NAME }__state-indicator` } key = { link . id } style = { style } > < div className = { `${ CLASS_NAME }__state-indicator-container` } > < div className = { `${ CLASS_NAME }__state-indicator-body` } > { renderedState } { renderedErrors } < /div> < /div> < /div>; } else { return null ; } } ) ; } private renderLinkStateHighlighting ( ) { const { editor } = this . props ; return editor . model . links . map ( link => { if ( editor . temporaryState . links . has ( link . data ) ) { const path = this . calculateLinkPath ( link ) ; return ( < path key = { link . id } d = { path } fill = { 'none' } stroke = { 'grey' } strokeWidth = { 5 } strokeOpacity = { 0.5 } strokeDasharray = { '8 8' } / > ) ; } const event = editor . authoringState . links . get ( link . data ) ; const isDeletedLink = AuthoringState . isDeletedLink ( editor . authoringState , link . data ) ; const isUncertainLink = AuthoringState . isUncertainLink ( editor . authoringState , link . data ) ; if ( event || isDeletedLink || isUncertainLink ) { const path = this . calculateLinkPath ( link ) ; let color ; if ( isDeletedLink ) { color = 'red' ; } else if ( isUncertainLink ) { color = 'blue' ; } else if ( event && event . type === AuthoringKind . ChangeLink ) { color = event . before ? 'blue' : 'green' ; } return ( < path key = { link . id } d = { path } fill = { 'none' } stroke = { color } strokeWidth = { 5 } strokeOpacity = { 0.5 } / > ) ; } return null ; } ) ; } private getLinkStateLabelPosition ( link ) { if ( link . labelBounds ) { const { x , y } = link . labelBounds ; return { x , y : y - LINK_LABEL_MARGIN / 2 } ; } else { const polyline = this . calculatePolyline ( link ) ; const polylineLength = computePolylineLength ( polyline ) ; return getPointAlongPolyline ( polyline , polylineLength / 2 ) ; } } private renderLinkErrors ( linkModel ) { const { editor } = this . props ; const { validationState } = editor ; const validation = validationState . links . get ( linkModel ) ; if ( ! validation ) { return null ; } const title = validation . errors . map ( error => error . message ) . join ( '\n' ) ; return this . renderErrorIcon ( title , validation ) ; } private renderErrorIcon ( title , validation ) { return < div className = { `${ CLASS_NAME }__item-error` } title = { title } > { validation . loading ? < HtmlSpinner width = { 15 } height = { 17 } / > : < div className = { `${ CLASS_NAME }__item-error-icon` } />} { ( ! validation . loading && validation . errors . length > 0 ) ? validation . errors . length : undefined } < /div>; } render ( ) { const { editor , paperTransform } = this . props ; const { scale , originX , originY } = paperTransform ; if ( ! editor . inAuthoringMode ) { return null ; } const htmlTransformStyle = { position : 'absolute' , left : 0 , top : 0 , transform : `scale(${ scale },${ scale })translate(${ originX }px,${ originY }px)` , } ; return < div className = { `${ CLASS_NAME }` } > < TransformedSvgCanvas paperTransform = { paperTransform } style = { { overflow : 'visible' , pointerEvents : 'none' } } > { this . renderLinkStateHighlighting ( ) } < /TransformedSvgCanvas> < div className = { `${ CLASS_NAME }__validation-layer` } style = { htmlTransformStyle } > { this . renderLinkStateLabels ( ) } < /div> < /div>; } }