import { ISettingRegistry } from '@jupyterlab/settingregistry' ; import { ArrayExt , StringExt } from '@lumino/algorithm' ; import { ReadonlyJSONArray } from '@lumino/coreutils' ; import { TopWhitespaceStyle , ShortcutUIStyle } from '../componentStyle/ShortcutUIStyle' ; import { ShortcutObject , ErrorObject , TakenByObject } from '../index' ; import { IShortcutUIexternal } from '../ShortcutWidget' ; import { ShortcutList } from './ShortcutList' ; import { TopNav } from './TopNav' ; import * as React from 'react' ; const enum MatchType { Label , Category , Split , Default } export const enum UISize { Regular , Small , Tiny } /** Props for ShortcutUI component */ export interface IShortcutUIProps { external : IShortcutUIexternal ; height : number ; width : number ; } /** State for ShortcutUI component */ export interface IShortcutUIState { shortcutList : { [ index ] : ShortcutObject ; } ; filteredShortcutList : ShortcutObject [ ] ; shortcutsFetched : boolean ; searchQuery : string ; showSelectors : boolean ; currentSort : string ; keyBindingsUsed : { [ index ] : TakenByObject ; } ; contextMenu : any ; } /** Normalize the query text for a fuzzy search. */ function normalizeQuery ( text ) { return text . replace ( /\s+/g , '' ) . toLowerCase ( ) ; } /** Perform a fuzzy search on a single command item. */ function fuzzySearch ( item , query ) { const category = item . category . toLowerCase ( ) ; const label = item [ 'label' ] . toLowerCase ( ) ; const source = `${ category } ${ label }` ; let score = Infinity ; let indices = null ; const rgx = /\b\w/g ; while ( true ) { const rgxMatch = rgx . exec ( source ) ; if ( ! rgxMatch ) { break ; } const match = StringExt . matchSumOfDeltas ( source , query , rgxMatch . index ) ; if ( ! match ) { break ; } if ( match && match . score <= score ) { score = match . score ; indices = match . indices ; } } if ( ! indices || score === Infinity ) { return null ; } const pivot = category . length + 1 ; const j = ArrayExt . lowerBound ( indices , pivot , ( a , b ) => a - b ) ; const categoryIndices = indices . slice ( 0 , j ) ; const labelIndices = indices . slice ( j ) ; for ( let i = 0 , n = labelIndices . length ; i < n ; ++ i ) { labelIndices [ i ] -= pivot ; } if ( categoryIndices . length === 0 ) { return { matchType : MatchType . Label , categoryIndices : null , labelIndices , score , item } ; } if ( labelIndices . length === 0 ) { return { matchType : MatchType . Category , categoryIndices , labelIndices : null , score , item } ; } return { matchType : MatchType . Split , categoryIndices , labelIndices , score , item } ; } /** Perform a fuzzy match on an array of command items. */ function matchItems ( items , query ) { query = normalizeQuery ( query ) ; let scores = [ ] ; let itemList = Object . keys ( items ) ; for ( let i = 0 , n = itemList . length ; i < n ; ++ i ) { let item = items [ itemList [ i ] ] ; if ( ! query ) { scores . push ( { matchType : MatchType . Default , categoryIndices : null , labelIndices : null , score : 0 , item } ) ; continue ; } let score = fuzzySearch ( item , query ) ; if ( ! score ) { continue ; } scores . push ( score ) ; } return scores ; } /** Transform SettingRegistry's shortcut list to list of ShortcutObjects */ function getShortcutObjects ( external , settings ) { const shortcuts = settings . composite . shortcuts as ReadonlyJSONArray ; let shortcutObjects = { } ; shortcuts . forEach ( ( shortcut ) => { let key = shortcut . command + '_' + shortcut . selector ; if ( Object . keys ( shortcutObjects ) . indexOf ( key ) !== - 1 ) { let currentCount = shortcutObjects [ key ] . numberOfShortcuts ; shortcutObjects [ key ] . keys [ currentCount ] = shortcut . keys ; shortcutObjects [ key ] . numberOfShortcuts ++ ; } else { let shortcutObject = new ShortcutObject ( ) ; shortcutObject . commandName = shortcut . command ; let label = external . getLabel ( shortcut . command ) ; if ( ! label ) { label = shortcut . command . split ( ':' ) [ 1 ] ; } shortcutObject . label = label ; shortcutObject . category = shortcut . command . split ( ':' ) [ 0 ] ; shortcutObject . keys [ 0 ] = shortcut . keys ; shortcutObject . selector = shortcut . selector ; shortcutObject . source = 'Default' ; shortcutObject . id = key ; shortcutObject . numberOfShortcuts = 1 ; shortcutObjects [ key ] = shortcutObject ; } } ) ; const userShortcuts = settings . user . shortcuts ; userShortcuts . forEach ( ( userSetting ) => { const command = userSetting . command ; const selector = userSetting . selector ; const keyTo = command + '_' + selector ; shortcutObjects [ keyTo ] . source = 'Custom' ; } ) ; return shortcutObjects ; } /** Get list of all shortcut keybindings currently in use
 * An object where keys are unique keyBinding_selector and values are shortcut objects **/ function getKeyBindingsUsed ( shortcutObjects ) { let keyBindingsUsed = { } ; Object . keys ( shortcutObjects ) . forEach ( ( shortcut ) => { Object . keys ( shortcutObjects [ shortcut ] . keys ) . forEach ( ( key ) => { const takenBy = new TakenByObject ( shortcutObjects [ shortcut ] ) ; takenBy . takenByKey = key ; keyBindingsUsed [ shortcutObjects [ shortcut ] . keys [ key ] . join ( ' ' ) + '_' + shortcutObjects [ shortcut ] . selector ] = takenBy ; } ) ; } ) ; return keyBindingsUsed ; } /** Top level React component for widget */ export class ShortcutUI extends React . Component < IShortcutUIProps , IShortcutUIState > { constructor ( props ) { super ( props ) ; this . state = { shortcutList : { } , filteredShortcutList : new Array < ShortcutObject > ( ) , shortcutsFetched : false , searchQuery : '' , showSelectors : false , currentSort : 'category' , keyBindingsUsed : { } , contextMenu : this . props . external . createMenu ( ) } ; } /** Fetch shortcut list on mount */ componentDidMount ( ) { void this . _refreshShortcutList ( ) ; } /** Fetch shortcut list from SettingRegistry  */ private async _refreshShortcutList ( ) { const shortcuts = await this . props . external . getAllShortCutSettings ( ) ; const shortcutObjects = getShortcutObjects ( this . props . external , shortcuts ) ; this . setState ( { shortcutList : shortcutObjects , filteredShortcutList : this . searchFilterShortcuts ( shortcutObjects ) , shortcutsFetched : true } , ( ) => { let keyBindingsUsed = getKeyBindingsUsed ( shortcutObjects ) ; this . setState ( { keyBindingsUsed } ) ; this . sortShortcuts ( ) ; } ) ; } /** Set the current seach query */ updateSearchQuery = ( event ) : void => { this . setState ( { searchQuery : ( event . target as any ) [ 'value' ] } , ( ) => this . setState ( { filteredShortcutList : this . searchFilterShortcuts ( this . state . shortcutList ) } , ( ) => { this . sortShortcuts ( ) ; } ) ) ; } ; /** Filter shortcut list using current search query */ private searchFilterShortcuts ( shortcutObjects ) { const filteredShortcuts = matchItems ( shortcutObjects , this . state . searchQuery ) . map ( ( item ) => { return item . item ; } ) ; return filteredShortcuts ; } /** Reset all shortcuts to their defaults */ resetShortcuts = async ( ) => { const settings = await this . props . external . getAllShortCutSettings ( ) ; for ( const key of Object . keys ( settings . user ) ) { await this . props . external . removeShortCut ( key ) ; } await this . _refreshShortcutList ( ) ; } ; /** Set new shortcut for command, refresh state */ handleUpdate = async ( shortcutObject , keys ) => { const settings = await this . props . external . getAllShortCutSettings ( ) ; const userShortcuts = settings . user . shortcuts as ReadonlyJSONArray ; const newUserShortcuts = [ ] ; let found = false ; for ( let shortcut of userShortcuts ) { if ( shortcut [ 'command' ] === shortcutObject . commandName && shortcut [ 'selector' ] === shortcutObject . selector ) { newUserShortcuts . push ( { command : shortcut [ 'command' ] , selector : shortcut [ 'selector' ] , keys : keys } ) ; found = true ; } else { newUserShortcuts . push ( shortcut ) ; } } if ( ! found ) { newUserShortcuts . push ( { command : shortcutObject . commandName , selector : shortcutObject . selector , keys : keys } ) ; } await settings . set ( 'shortcuts' , newUserShortcuts ) ; await this . _refreshShortcutList ( ) ; } ; /** Delete shortcut for command, refresh state */ deleteShortcut = async ( shortcutObject , shortcutId ) => { await this . handleUpdate ( shortcutObject , [ '' ] ) ; await this . _refreshShortcutList ( ) ; } ; /** Reset a specific shortcut to its default settings */ resetShortcut = async ( shortcutObject ) => { const settings = await this . props . external . getAllShortCutSettings ( ) ; const userShortcuts = settings . user . shortcuts as ReadonlyJSONArray ; const newUserShortcuts = [ ] ; for ( let shortcut of userShortcuts ) { if ( shortcut [ 'command' ] !== shortcutObject . commandName || shortcut [ 'selector' ] !== shortcutObject . selector ) { newUserShortcuts . push ( shortcut ) ; } } await settings . set ( 'shortcuts' , newUserShortcuts ) ; await this . _refreshShortcutList ( ) ; } ; /** Opens advanced setting registry */ openAdvanced = ( ) : void => { void this . props . external . openAdvanced ( ) ; } ; /** Toggles showing command selectors */ toggleSelectors = ( ) : void => { this . setState ( { showSelectors : ! this . state . showSelectors } ) ; } ; /** Set the current list sort order */ updateSort = ( value ) : void => { if ( value !== this . state . currentSort ) { this . setState ( { currentSort : value } , this . sortShortcuts ) ; } } ; /** Sort shortcut list using current sort property  */ sortShortcuts ( ) { const shortcuts = this . state . filteredShortcutList ; let filterCritera = this . state . currentSort ; if ( filterCritera === 'command' ) { filterCritera = 'label' ; } if ( filterCritera !== '' ) { shortcuts . sort ( ( a , b ) => { const compareA = a . get ( filterCritera ) ; const compareB = b . get ( filterCritera ) ; if ( compareA < compareB ) { return - 1 ; } else if ( compareA > compareB ) { return 1 ; } else { return a [ 'label' ] < b [ 'label' ] ? - 1 : a [ 'label' ] > b [ 'label' ] ? 1 : 0 ; } } ) ; } this . setState ( { filteredShortcutList : shortcuts } ) ; } /** Sort shortcut list so that an error row is right below the one currently being set */ sortConflict = ( newShortcut , takenBy ) : void => { const shortcutList = this . state . filteredShortcutList ; if ( shortcutList . filter ( shortcut => shortcut . id === 'error_row' ) . length === 0 ) { const errorRow = new ErrorObject ( ) ; errorRow . takenBy = takenBy ; errorRow . id = 'error_row' ; shortcutList . splice ( shortcutList . indexOf ( newShortcut ) + 1 , 0 , errorRow ) ; errorRow . hasConflict = true ; this . setState ( { filteredShortcutList : shortcutList } ) ; } } ; /** Remove conflict flag from all shortcuts */ clearConflicts = ( ) : void => { /** Remove error row */ const shortcutList = this . state . filteredShortcutList . filter ( shortcut => shortcut . id !== 'error_row' ) ; shortcutList . forEach ( ( shortcut ) => { shortcut . hasConflict = false ; } ) ; this . setState ( { filteredShortcutList : shortcutList } ) ; } ; contextMenu = ( event , commandIDs ) => { event . persist ( ) ; this . setState ( { contextMenu : this . props . external . createMenu ( ) } , ( ) => { event . preventDefault ( ) ; for ( let command of commandIDs ) { this . state . contextMenu . addItem ( { command } ) ; } this . state . contextMenu . open ( event . clientX , event . clientY ) ; } ) ; } ; render ( ) { if ( ! this . state . shortcutsFetched ) { return null ; } return ( < div className = { ShortcutUIStyle } id = "jp-shortcutui" > < div className = { TopWhitespaceStyle } /> < TopNav updateSearchQuery = { this . updateSearchQuery } resetShortcuts = { this . resetShortcuts } openAdvanced = { this . openAdvanced } toggleSelectors = { this . toggleSelectors } showSelectors = { this . state . showSelectors } updateSort = { this . updateSort } currentSort = { this . state . currentSort } width = { this . props . width } external = { this . props . external } / > < ShortcutList shortcuts = { this . state . filteredShortcutList } resetShortcut = { this . resetShortcut } handleUpdate = { this . handleUpdate } deleteShortcut = { this . deleteShortcut } showSelectors = { this . state . showSelectors } keyBindingsUsed = { this . state . keyBindingsUsed } sortConflict = { this . sortConflict } clearConflicts = { this . clearConflicts } height = { this . props . height } errorSize = { this . props . width < 775 ? UISize . Small : UISize . Regular } contextMenu = { this . contextMenu } external = { this . props . external } / > < /div>); } }