import * as React from 'react' ; import { ElementModel , ElementIri } from '../data/model' ; import { DiagramView } from '../diagram/view' ; import { cloneSet } from '../viewUtils/collections' ; import { Debouncer } from '../viewUtils/async' ; import { EventObserver } from '../viewUtils/events' ; import { ListElementView , startDragElements } from './listElementView' ; const CLASS_NAME = 'graph-explorer-search-results' ; export interface SearchResultProps { view : DiagramView ; items : ReadonlyArray < ElementModel > ; selection : ReadonlySet < ElementIri > ; onSelectionChanged : ( newSelection : ReadonlySet < ElementIri > ) => void ; highlightText ? : string ; default useDragAndDrop ? : boolean ; } const enum Direction { Up , Down } export class SearchResults extends React . Component < SearchResultProps , { } > { static defaultProps : Partial < SearchResultProps > = { useDragAndDrop : true , } ; private readonly listener = new EventObserver ( ) ; private readonly delayedChangeCells = new Debouncer ( ) ; private root : HTMLElement ; private startSelection = 0 ; private endSelection = 0 ; constructor ( props ) { super ( props ) ; this . state = { selection : props . selection || { } , } ; } render ( ) { const items = this . props . items || [ ] ; return ( < ul className = { CLASS_NAME } ref = { this . onRootMount } tabIndex = { - 1 } onFocus = { this . addKeyListener } onBlur = { this . removeKeyListener } > { items . map ( this . renderResultItem ) } < /ul>); } private onRootMount = ( root ) => { this . root = root ; } ; private renderResultItem = ( model ) => { const { useDragAndDrop } = this . props ; const canBeSelected = this . canBeSelected ( model ) ; return ( < ListElementView key = { model . id } model = { model } view = { this . props . view } highlightText = { this . props . highlightText } disabled = { ! canBeSelected } selected = { this . props . selection . has ( model . id ) } onClick = { canBeSelected ? this . onItemClick : undefined } onDragStart = { useDragAndDrop ? ( e ) => { const { selection } = this . props ; const iris = [ ] ; selection . forEach ( ( iri ) => iris . push ( iri ) ) ; if ( ! selection . has ( model . id ) ) { iris . push ( model . id ) ; } return startDragElements ( e , iris ) ; } : undefined } />); } ; componentDidMount ( ) { this . listener . listen ( this . props . view . model . events , 'changeCells' , ( ) => { this . delayedChangeCells . call ( this . onChangeCells ) ; } ) ; } private onChangeCells = ( ) => { const { items , selection } = this . props ; if ( selection . size === 0 ) { if ( items && items . length > 0 ) { this . forceUpdate ( ) ; } } else { const newSelection = cloneSet ( selection ) ; for ( const element of this . props . view . model . elements ) { if ( element . group === undefined && selection . has ( element . iri ) ) { newSelection . delete ( element . iri ) ; } } this . updateSelection ( newSelection ) ; } } ; UNSAFE_componentWillReceiveProps ( props ) { this . setState ( { selection : props . selection || { } } ) ; } componentWillUnmount ( ) { this . removeKeyListener ( ) ; this . listener . stopListening ( ) ; this . delayedChangeCells . dispose ( ) ; } private updateSelection ( selection ) { const { onSelectionChanged } = this . props ; onSelectionChanged ( selection ) ; } private addKeyListener = ( ) => { document . addEventListener ( 'keydown' , this . onKeyUp ) ; } ; private removeKeyListener = ( ) => { document . removeEventListener ( 'keydown' , this . onKeyUp ) ; } ; private onKeyUp = ( event ) => { const { items } = this . props ; const isPressedUp = event . keyCode === 38 || event . which === 38 ; const isPressDown = event . keyCode === 40 || event . which === 40 ; if ( isPressedUp || isPressDown ) { if ( event . shiftKey ) { if ( isPressedUp ) { this . endSelection = this . getNextIndex ( this . endSelection , Direction . Up ) ; } else if ( isPressDown ) { this . endSelection = this . getNextIndex ( this . endSelection , Direction . Down ) ; } const startIndex = Math . min ( this . startSelection , this . endSelection ) ; const finishIndex = Math . max ( this . startSelection , this . endSelection ) ; const selection = this . selectRange ( startIndex , finishIndex ) ; this . updateSelection ( selection ) ; this . focusOn ( this . endSelection ) ; } else { const startIndex = Math . min ( this . startSelection , this . endSelection ) ; const finishIndex = Math . max ( this . startSelection , this . endSelection ) ; if ( isPressedUp ) { this . startSelection = this . getNextIndex ( startIndex , Direction . Up ) ; } else if ( isPressDown ) { this . startSelection = this . getNextIndex ( finishIndex , Direction . Down ) ; } this . endSelection = this . startSelection ; const focusElement = items [ this . startSelection ] ; const newSelection = new Set < ElementIri > ( ) ; newSelection . add ( focusElement . id ) ; this . updateSelection ( newSelection ) ; this . focusOn ( this . startSelection ) ; } } event . preventDefault ( ) ; } ; private onItemClick = ( event , model ) => { event . preventDefault ( ) ; const { items , selection , onSelectionChanged } = this . props ; const previouslySelected = selection . has ( model . id ) ; const modelIndex = items . indexOf ( model ) ; let newSelection ; if ( event . shiftKey && this . startSelection !== - 1 ) { const start = Math . min ( this . startSelection , modelIndex ) ; const end = Math . max ( this . startSelection , modelIndex ) ; newSelection = this . selectRange ( start , end ) ; } else { this . endSelection = this . startSelection = modelIndex ; const ctrlKey = event . ctrlKey || event . metaKey ; if ( ctrlKey ) { newSelection = cloneSet ( selection ) ; if ( selection . has ( model . id ) ) { newSelection . delete ( model . id ) ; } else { newSelection . add ( model . id ) ; } } else { newSelection = new Set < ElementIri > ( ) ; newSelection . add ( model . id ) ; } } onSelectionChanged ( newSelection ) ; } ; private selectRange ( start , end ) { const { items } = this . props ; const selection = new Set < ElementIri > ( ) ; for ( let i = start ; i <= end ; i ++ ) { const selectedModel = items [ i ] ; if ( this . canBeSelected ( selectedModel ) ) { selection . add ( selectedModel . id ) ; } } return selection ; } private getNextIndex ( startIndex , direction ) { const { items } = this . props ; if ( items . length === 0 ) { return startIndex ; } const indexDelta = direction === Direction . Up ? - 1 : 1 ; for ( let step = 1 ; step < items . length ; step ++ ) { let nextIndex = startIndex + step * indexDelta ; if ( nextIndex < 0 ) { nextIndex += items . length ; } if ( nextIndex >= items . length ) { nextIndex -= items . length ; } if ( this . canBeSelected ( items [ nextIndex ] ) ) { return nextIndex ; } } return startIndex ; } private canBeSelected ( model ) { const alreadyOnDiagram = this . props . view . model . elements . findIndex ( ( element ) => element . iri === model . id && element . group === undefined ) >= 0 ; return ! this . props . useDragAndDrop || ! alreadyOnDiagram ; } private focusOn ( index ) { const scrollableContainer = this . root . parentElement ; const containerBounds = scrollableContainer . getBoundingClientRect ( ) ; const item = this . root . children . item ( index ) as HTMLElement ; const itemBounds = item . getBoundingClientRect ( ) ; const itemTop = itemBounds . top - containerBounds . top ; const itemBottom = itemBounds . bottom - containerBounds . top ; if ( itemTop < 0 ) { scrollableContainer . scrollTop += itemTop ; } else if ( itemBottom > containerBounds . height ) { scrollableContainer . scrollTop += itemBottom - containerBounds . height ; } item . focus ( ) ; } }