/**
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */ import * as React from 'react' ; import { Editor } from 'slate-react' ; import { Text } from 'slate' ; import * as Fuse from 'fuse.js' ; import { IOption , IPickerProps , NodeTypes } from './APEModels' ; import MentionPlugin , { defaultPickerProps } from './MentionPlugin' ; import OptionalPlugin from './OptionalPlugin' ; import Picker from './Picker' ; import * as Utilities from './utilities' ; import { FuseResult , MatchedOption , convertMatchedTextIntoMatchedOption } from '../../FuseMatch' ; import classnames from 'classnames' ; import './PayloadEditor.css' ; const fuseOptions = { shouldSort : true , includeMatches : true , threshold : 0.6 , location : 0 , distance : 100 , maxPatternLength : 32 , minMatchCharLength : 1 , keys : [ "name" ] } ; export type SlateValue = any ; const testAttribute = 'data-testid' ; interface Props { options : IOption [ ] ; value : SlateValue ; disabled : boolean ; placeholder : string ; onChange : ( value : SlateValue ) => void ; onSubmit : ( ) => void ; [ testAttribute ] : string ; } interface State { maxDisplayedOptions : number ; highlightIndex : number ; matchedOptions : MatchedOption < IOption > [ ] ; menuProps : IPickerProps ; } const initialState = { highlightIndex : 0 , matchedOptions : [ ] as MatchedOption < IOption > [ ] , maxDisplayedOptions : 4 , menuProps : defaultPickerProps , } ; export default class PayloadEditor extends React . Component < Props , State > { fuse : Fuse ; menuRef = React . createRef < HTMLDivElement > ( ) ; plugins : any [ ] ; constructor ( props ) { super ( props ) ; this . fuse = new Fuse ( this . props . options , fuseOptions ) ; this . state = { ... initialState , matchedOptions : this . getDefaultMatchedOptions ( initialState . highlightIndex ) } ; this . plugins = [ OptionalPlugin ( ) , MentionPlugin ( { onChangeMenuProps : this . onChangePickerProps } ) ] ; } UNSAFE_componentWillReceiveProps ( nextProps ) { if ( this . props . options . length !== nextProps . options . length ) { this . fuse = new Fuse ( nextProps . options , fuseOptions ) ; } } getDefaultMatchedOptions ( highlightIndex ) { return this . props . options . map < MatchedOption < IOption > > ( ( option , i ) => ( { highlighted : highlightIndex === i , matchedStrings : [ { text : option . name , matched : false } ] , original : option } ) ) . sort ( ( a , b ) => a . original . name . localeCompare ( b . original . name ) ) ; } onChangeValue = ( change ) => { const { value } = change ; this . props . onChange ( value ) ; const menu = this . menuRef . current ; if ( ! menu ) { return ; } if ( ! value . isFocused ) { this . onChangePickerProps ( { isVisible : false } ) ; return ; } const relativeParent = Utilities . getRelativeParent ( menu . parentElement ) ; const relativeRect = relativeParent . getBoundingClientRect ( ) ; const selection = window . getSelection ( ) ; if ( ! selection ) { this . onChangePickerProps ( { isVisible : false } ) ; return ; } const isWithinMentionNode = value . inlines . size > 0 && value . inlines . last ( ) . type === NodeTypes . Mention ; if ( ! isWithinMentionNode ) { this . onChangePickerProps ( { isVisible : false } ) ; return ; } const isNodeCompleted = change . value . inlines . last ( ) . data . get ( 'completed' ) ; const range = selection . getRangeAt ( 0 ) ; const selectionBoundingRect = range . getBoundingClientRect ( ) ; const top = ( selectionBoundingRect . bottom - relativeRect . top ) ; const left = ( selectionBoundingRect . left - relativeRect . left ) + window . scrollX ; const bottom = relativeRect . height - ( selectionBoundingRect . top - relativeRect . top ) ; const searchText = ( ( value . inlines . size > 0 ) ? ( value . inlines . first ( ) . text as string ) . substr ( 1 ) : '' ) ; const menuProps = { isVisible : ! isNodeCompleted , bottom , left , top , searchText , } ; this . onChangePickerProps ( menuProps ) ; } ; onKeyDown = ( event , change ) => { const fn = this [ `on${ event . key }` ] ; if ( typeof fn === "function" ) { return fn . call ( this , event , change ) ; } } ; onArrowUp ( event , change ) { if ( ! this . state . menuProps . isVisible ) { return undefined ; } event . preventDefault ( ) ; this . setState ( prevState => { const nextIndex = prevState . highlightIndex - 1 ; const minIndex = 0 ; const maxIndex = Math . max ( 0 , prevState . matchedOptions . length - 1 ) ; return { highlightIndex : nextIndex < minIndex ? maxIndex : nextIndex } ; } ) ; return true ; } onArrowDown ( event , change ) { if ( ! this . state . menuProps . isVisible ) { return undefined ; } event . preventDefault ( ) ; this . setState ( prevState => { const nextIndex = prevState . highlightIndex + 1 ; const minIndex = 0 ; const maxIndex = Math . max ( 0 , prevState . matchedOptions . length - 1 ) ; return { highlightIndex : nextIndex > maxIndex ? minIndex : nextIndex } ; } ) ; return true ; } onEnter ( event , change ) { event . preventDefault ( ) ; if ( event . shiftKey ) { this . props . onSubmit ( ) ; return true ; } if ( ! this . state . menuProps . isVisible || this . state . matchedOptions . length === 0 ) { return true ; } const matchedOption = this . state . matchedOptions [ this . state . highlightIndex ] ; if ( ! matchedOption ) { throw new Error ( `You attempted to access matched option at index ${ this . state . highlightIndex }, but there are only ${ this . state . matchedOptions . length } items` ) ; } this . onCompleteNode ( event , change , matchedOption . original ) ; return true ; } onEscape ( event , change ) { if ( ! this . state . menuProps . isVisible ) { return ; } const inline = change . value . inlines . find ( ( i ) => i . type === NodeTypes . Mention ) ; if ( ! inline ) { return ; } change . removeNodeByKey ( inline . key ) ; return true ; } onTab ( event , change ) { if ( ! this . state . menuProps . isVisible || this . state . matchedOptions . length === 0 ) { return ; } const matchedOption = this . state . matchedOptions [ this . state . highlightIndex ] ; if ( ! matchedOption ) { throw new Error ( `You attempted to access matched option at index ${ this . state . highlightIndex }, but there are only ${ this . state . matchedOptions . length } items` ) ; } return this . onCompleteNode ( event , change , matchedOption . original ) ; } onChangePickerProps = ( menuProps ) => { const matchedOptions = ( typeof menuProps . searchText !== 'string' || menuProps . searchText === "" ) ? this . getDefaultMatchedOptions ( this . state . highlightIndex ) : this . fuse . search < FuseResult < IOption > > ( menuProps . searchText ) . map ( result => convertMatchedTextIntoMatchedOption ( result . item . name , result . matches [ 0 ] . indices , result . item ) ) ; this . setState ( prevState => ( { matchedOptions , menuProps : { ... prevState . menuProps , ... menuProps } } ) ) ; return true ; } ; onClickOption = ( option ) => { const change = this . props . value . change ( ) ; this . onCompleteNode ( undefined , change , option ) ; this . setState ( prevState => ( { menuProps : { ... prevState . menuProps , isVisible : false } } ) ) ; this . props . onChange ( change . value ) ; } ; render ( ) { const matchedOptions = this . state . matchedOptions . map ( ( o , i ) => ( { ... o , highlighted : i === this . state . highlightIndex } ) ) ; const editorClassnames = classnames ( { 'editor' : true , 'editor--disabled' : this . props . disabled , 'editor--active' : this . props . value ?. isFocused , } ) ; return < div className = "editor-container" data - testid = { this . props [ testAttribute ] } > < Picker ref = { this . menuRef } { ... this . state . menuProps } matchedOptions = { matchedOptions } onClickOption = { this . onClickOption } / > < Editor className = { editorClassnames } placeholder = { this . props . placeholder } value = { this . props . value } onChange = { this . onChangeValue } onKeyDown = { this . onKeyDown } plugins = { this . plugins } readOnly = { this . props . disabled } / > < /div>; } private onCompleteNode ( event , change , option ) { if ( ! this . state . menuProps . isVisible || this . state . matchedOptions . length === 0 ) { return undefined ; } event ?. preventDefault ( ) ; const textNode = change . value . texts . last ( ) ; if ( textNode ) { change . replaceNodeByKey ( textNode . key , Text . fromJSON ( { "kind" : "text" , "leaves" : [ { "kind" : "leaf" , "text" : `$${ option . name }` , "marks" : [ ] as any [ ] } ] } ) ) . collapseToStartOfNextText ( ) ; } else { console . warn ( `Current selection did not contain any text nodes to insert option name into` , change . value . texts ) ; } const inline = change . value . inlines . find ( ( i ) => i . type === NodeTypes . Mention ) ; if ( inline ) { change . setNodeByKey ( inline . key , { data : { ... inline . get ( 'data' ) . toJS ( ) , option , completed : true } } ) ; } else { console . warn ( `Could not find any inlines matching Mention type` , change . value . inlines ) ; } change . collapseToStartOfNextText ( ) ; if ( this . menuRef . current ) { this . menuRef . current . scrollTop = 0 ; } this . setState ( { highlightIndex : 0 } ) ; return true ; } }